03一文字ずつのマスク
文字ごとに overflow: hidden の箱をかぶせ --i の分だけ遅らせるので、下から一文字ずつ押し上がります。箱の外は切れているため床から生えるように見えます。
使いどころ見出し一行が主役のヒーロー
overflow: hidden--ianimation-delay

<main class="stage">
<div class="tt is-demo" id="tt">
<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>
<span class="tt__line" aria-hidden="true"></span>
<div class="tt__mbox"><p class="tt__sub">物語が積もる場所</p></div>
<div class="tt__mbox"><button class="tt__go" type="button">購読する</button></div>
<button class="tt__skip" id="tt-skip" type="button">スキップ</button>
<p class="tt__live" id="tt-live" aria-live="polite"></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 낱글자 마스크 스태거 — 글자마다 overflow: hidden 상자를 씌우고 --i 값만큼 animation-delay 를 줘
// 아래에서 한 글자씩 밀려 올라오게 하는 인트로. 상자 밖은 잘려 있어 글자가 바닥에서 자라는 것처럼 보인다.
// 보조문과 버튼도 같은 마스크 기법으로 올라온다 — 이 데모에는 opacity 페이드가 아예 없어
// 반투명 유령 프레임이 생길 수 없다. 낱글자의 기본 transform 은 "올라온 자리"라
// 건너뛰기·Esc·재방문·reduced-motion 은 애니메이션을 끄는 것만으로 완성된 제목이 된다.
// 밑줄은 scaleX 채움 막대라 transform-origin 을 시작 변(왼쪽)에 둔다 — 붙는 지점이 있는 요소의 예외.
// 대비 실측(run/016/_probe/a1_대비.py): 낱글자 $subject-ink on $stage-orange 5.50:1(큰 글자 기준 3, 일반 기준 4.5도 통과) ·
// 보조문 $subject-ink on $stage-orange 5.50:1 · CTA $subject-cream on $subject-ink 17.11:1
$duration: $dur-enter; // 정적(실사용) 재생 시간 — 변형1: $dur-base
$easing: $ease-spring; // 변형2: $ease-pop
$color: $subject-ink; // 낱글자 색 — 변형3: $stage-orange 대신 밝은 면에서는 글자색 토큰을 다시 잡는다
@include stage($stage-orange);
.tt {
position: fixed; inset: 0;
display: flex; flex-direction: column; align-items: center; justify-content: center;
gap: $sp-3; text-align: center;
}
// ── 낱글자 마스크 줄. 상자가 곧 마스크고 글자는 그 안에서만 보인다
.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;
}
// 밑줄 — 제목이 자라난 바닥. 채움 막대는 시작 변에서 자란다 (scaleX + origin left)
.tt__line { display: block; width: min(60%, 220px); height: 2px; background: $color; transform-origin: 0 50%; }
// 보조문·버튼의 마스크 상자 — 낱글자와 같은 기법
.tt__mbox { overflow: hidden; }
.tt__sub {
margin: 0; color: $subject-ink;
font-weight: 500; font-size: clamp(12px, 3vw, 14px); line-height: 1.5;
}
.tt__go {
padding: $sp-2 $sp-5; border: 0; border-radius: $r-pill;
background: $subject-ink; color: $subject-cream;
font-family: inherit; font-weight: 700; font-size: 13px; line-height: 1.4; cursor: pointer;
}
// ── 건너뛰기 (무대 우상단)
.tt__skip {
position: absolute; top: $sp-3; right: $sp-3; z-index: 4;
padding: $sp-1 $sp-3; border: 1px solid rgba($subject-ink, .32); border-radius: $r-pill;
background: rgba($subject-ink, .08); color: $subject-ink;
font-family: inherit; font-weight: 600; font-size: 12px; line-height: 1.4; cursor: pointer;
}
.tt__live {
position: absolute; width: 1px; height: 1px; overflow: hidden;
clip-path: inset(50%); white-space: nowrap;
}
// ── 정적(실사용): --i 만큼 지연되어 한 글자씩 올라온다. 낱개 속성 + fill-mode: both 로
// 지연 구간에는 0%(상자 바닥)가 채워진다
.tt__ch {
animation-name: ttChOnce; animation-duration: $duration; animation-timing-function: $easing;
animation-delay: calc(var(--i) * .05s); animation-fill-mode: both;
}
.tt__line {
animation-name: ttLineOnce; animation-duration: $duration; animation-timing-function: $easing;
animation-delay: .46s; animation-fill-mode: both;
}
.tt__sub {
animation-name: ttMaskOnce; animation-duration: $duration; animation-timing-function: $easing;
animation-delay: .54s; animation-fill-mode: both;
}
.tt__go {
animation-name: ttMaskOnce; animation-duration: $duration; animation-timing-function: $easing;
animation-delay: .64s; animation-fill-mode: both;
}
@keyframes ttChOnce { from { transform: translateY(112%); } to { transform: none; } }
@keyframes ttLineOnce { from { transform: scaleX(0); } to { transform: scaleX(1); } }
@keyframes ttMaskOnce { from { transform: translateY(110%); } to { transform: none; } }
// ── 격자 미리보기 루프(2초). 올라옴 0~16% + 낱글자 시차(2.5%씩, 마지막 글자가 31% 에 앉는다 —
// 동시에 움직이는 글자가 4~5개라 이 구간이 프레임 변화가 가장 크고 poster 도 여기서 뽑힌다) ·
// 내려감 62~78% + 시차(마지막 글자가 93% 에 다 내려간다 — 마지막 장 95.8% 는 전부 내려간 상태라
// 루프 이음이 깨끗하다). 내려감은 linear — 키프레임 안에서 덮는다(그 구간이 시작되는 키프레임에 적는다)
.tt.is-demo .tt__ch {
animation-name: ttChLoop; animation-duration: $dur-loop; animation-timing-function: $easing;
animation-iteration-count: infinite; animation-fill-mode: backwards;
animation-delay: calc(var(--i) * .05s);
}
.tt.is-demo .tt__line {
animation-name: ttLineLoop; animation-duration: $dur-loop; animation-timing-function: $easing;
animation-iteration-count: infinite; animation-fill-mode: backwards; animation-delay: 0s;
}
.tt.is-demo .tt__sub {
animation-name: ttSubLoop; animation-duration: $dur-loop; animation-timing-function: $easing;
animation-iteration-count: infinite; animation-fill-mode: backwards; animation-delay: 0s;
}
.tt.is-demo .tt__go {
animation-name: ttGoLoop; animation-duration: $dur-loop; animation-timing-function: $easing;
animation-iteration-count: infinite; animation-fill-mode: backwards; animation-delay: 0s;
}
@keyframes ttChLoop {
0% { transform: translateY(112%); }
16%, 62% { transform: none; animation-timing-function: linear; }
78%, 100% { transform: translateY(112%); }
}
@keyframes ttLineLoop {
0%, 12% { transform: scaleX(0); }
34%, 62% { transform: scaleX(1); animation-timing-function: linear; }
74%, 100% { transform: scaleX(0); }
}
@keyframes ttSubLoop {
0%, 34% { transform: translateY(110%); }
48%, 62% { transform: none; animation-timing-function: linear; }
74%, 100% { transform: translateY(110%); }
}
@keyframes ttGoLoop {
0%, 40% { transform: translateY(110%); }
54%, 64% { transform: none; animation-timing-function: linear; }
76%, 100% { transform: translateY(110%); }
}
// 건너뛰기·Esc·재방문: 애니메이션을 끄면 기본 상태(완성된 제목)가 드러난다
.tt.is-done .tt__ch, .tt.is-done .tt__line, .tt.is-done .tt__sub, .tt.is-done .tt__go { animation: none; }
.tt.is-done .tt__skip { display: none; }
// ── 좁은 화면: 글자와 간격을 줄여 무대(174px) 안에 들어온다
@media (max-width: 340px) {
.tt { gap: $sp-2; }
.tt__ch { font-size: 24px; }
.tt__line { width: 140px; }
}
@media (prefers-reduced-motion: reduce) {
// !important — .tt.is-demo 계열(클래스 3개) 특이도에 지지 않으려면 필요하다
.tt .tt__ch, .tt .tt__line, .tt .tt__sub, .tt .tt__go { 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: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.tt {
position: fixed;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
text-align: center;
}
.tt__row {
display: flex;
gap: 4px;
margin: 0;
}
.tt__box {
display: block;
overflow: hidden;
}
.tt__ch {
display: block;
color: #17141a;
font-weight: 800;
font-size: clamp(26px, 7vw, 36px);
line-height: 1.15;
}
.tt__line {
display: block;
width: min(60%, 220px);
height: 2px;
background: #17141a;
transform-origin: 0 50%;
}
.tt__mbox {
overflow: hidden;
}
.tt__sub {
margin: 0;
color: #17141a;
font-weight: 500;
font-size: clamp(12px, 3vw, 14px);
line-height: 1.5;
}
.tt__go {
padding: 8px 20px;
border: 0;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-family: inherit;
font-weight: 700;
font-size: 13px;
line-height: 1.4;
cursor: pointer;
}
.tt__skip {
position: absolute;
top: 12px;
right: 12px;
z-index: 4;
padding: 4px 12px;
border: 1px solid rgba(23, 20, 26, 0.32);
border-radius: 999px;
background: rgba(23, 20, 26, 0.08);
color: #17141a;
font-family: inherit;
font-weight: 600;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.tt__live {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.tt__ch {
animation-name: ttChOnce;
animation-duration: 600ms;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-delay: calc(var(--i) * 0.05s);
animation-fill-mode: both;
}
.tt__line {
animation-name: ttLineOnce;
animation-duration: 600ms;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-delay: 0.46s;
animation-fill-mode: both;
}
.tt__sub {
animation-name: ttMaskOnce;
animation-duration: 600ms;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-delay: 0.54s;
animation-fill-mode: both;
}
.tt__go {
animation-name: ttMaskOnce;
animation-duration: 600ms;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-delay: 0.64s;
animation-fill-mode: both;
}
@keyframes ttChOnce {
from {
transform: translateY(112%);
}
to {
transform: none;
}
}
@keyframes ttLineOnce {
from {
transform: scaleX(0);
}
to {
transform: scaleX(1);
}
}
@keyframes ttMaskOnce {
from {
transform: translateY(110%);
}
to {
transform: none;
}
}
.tt.is-demo .tt__ch {
animation-name: ttChLoop;
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: calc(var(--i) * 0.05s);
}
.tt.is-demo .tt__line {
animation-name: ttLineLoop;
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;
}
.tt.is-demo .tt__sub {
animation-name: ttSubLoop;
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;
}
.tt.is-demo .tt__go {
animation-name: ttGoLoop;
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 ttChLoop {
0% {
transform: translateY(112%);
}
16%, 62% {
transform: none;
animation-timing-function: linear;
}
78%, 100% {
transform: translateY(112%);
}
}
@keyframes ttLineLoop {
0%, 12% {
transform: scaleX(0);
}
34%, 62% {
transform: scaleX(1);
animation-timing-function: linear;
}
74%, 100% {
transform: scaleX(0);
}
}
@keyframes ttSubLoop {
0%, 34% {
transform: translateY(110%);
}
48%, 62% {
transform: none;
animation-timing-function: linear;
}
74%, 100% {
transform: translateY(110%);
}
}
@keyframes ttGoLoop {
0%, 40% {
transform: translateY(110%);
}
54%, 64% {
transform: none;
animation-timing-function: linear;
}
76%, 100% {
transform: translateY(110%);
}
}
.tt.is-done .tt__ch, .tt.is-done .tt__line, .tt.is-done .tt__sub, .tt.is-done .tt__go {
animation: none;
}
.tt.is-done .tt__skip {
display: none;
}
@media (max-width: 340px) {
.tt {
gap: 8px;
}
.tt__ch {
font-size: 24px;
}
.tt__line {
width: 140px;
}
}
@media (prefers-reduced-motion: reduce) {
.tt .tt__ch, .tt .tt__line, .tt .tt__sub, .tt .tt__go {
animation: none !important;
}
}(function () {
var root = document.getElementById('tt');
var live = document.getElementById('tt-live');
var KEY = 'godrich-intro-016-03';
// 인트로를 완료 상태로 보낸다 — 낱글자의 기본 상태는 올라온 자리라 애니메이션을 끄면 제목이 완성되어 있다
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('tt-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);
})();// TitleMaskStagger — vanilla 판(run/016/demo/03-intro-title-mask-stagger/index.html) 의 React 포트.
// 글자마다 overflow: hidden 상자를 씌우고 --i 값만큼 animation-delay 를 줘 한 글자씩 올라오는 인트로.
// @keyframes 본문은 vanilla 와 같은 글자다 (값을 다시 타이핑하지 않는다).
import { useEffect, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./TitleMaskStagger.module.scss";
export type TitleMaskStaggerProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string; "--i"?: number };
// ambient 타입에 맞춰 로컬 타입을 쓴다 — 이벤트 제네릭을 react 에서 import 하면 tsc 가 죽는다
type KeyEvt = { key: string };
const KEY = "godrich-intro-016-03";
const WORD = "GODRICH";
const LETTERS = WORD.split("");
const SUB = "이야기가 쌓이는 곳";
const GO = "구독하기";
const SKIP = "건너뛰기";
const SKIPPED = "인트로를 건너뛰었습니다";
export default function TitleMaskStagger(props: TitleMaskStaggerProps) {
const duration = props.duration ?? 600;
const easing = props.easing ?? "cubic-bezier(.2, .8, .2, 1)";
const color = props.color ?? "#17141a";
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.tt + (preview && !done ? " " + styles.isDemo : "") + (done ? " " + styles.isDone : "");
return (
<main className={styles.stage}>
<div className={rootCls} style={vars}>
<h1 className={styles.row} aria-label={WORD}>
{LETTERS.map((ch, i) => (
<span className={styles.box} aria-hidden="true" key={i} style={{ "--i": i } as CSSProperties}>
<span className={styles.ch}>{ch}</span>
</span>
))}
</h1>
<span className={styles.line} aria-hidden="true" />
<div className={styles.mbox}><p className={styles.sub}>{SUB}</p></div>
<div className={styles.mbox}><button className={styles.go} type="button">{GO}</button></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>
);
}03 一文字ずつのマスク — 計測 累積面積 6.092% · 動いたフレーム 21/23GODRICH オリジナル · SCSS と React は zip に同梱 · オープニング アニメーション css 9選 — イントロ アニメーション 入門
このデモが属する記事
オープニング アニメーション css 9選 — イントロ アニメーション 入門オープニング アニメーション css とは、ページが開いた最初の1〜2秒だけ画面を覆い、やがて退いて本来の画面を引き渡す演出のことです。スキップできてこそイントロ — 9つすべてにボタンと Esc と再訪の記録が備わり、その場で終わらせられ この記事のデモをすべて見る


