05カウントダウン導入
3・2・1 が steps(1, end) で 1 フレームに切り替わり、数字ごとに scale が一段ずつ縮むので、数える間に画面が近づきます。
使いどころイベント・配信の開始画面
steps(1, end)font-variant-numericscale

<main class="stage">
<div class="cd is-demo" id="cd">
<div class="cd__site">
<div class="cd__box">
<p class="cd__kicker">ライブ配信</p>
<h1 class="cd__title">今夜九時から</h1>
<p class="cd__sub">カウントダウンが終わるとすぐ入場できます</p>
<a class="cd__cta" href="#">入場する</a>
</div>
</div>
<div class="cd__nums" aria-hidden="true">
<span class="cd__n cd__n--3"><span class="cd__pop">3</span></span>
<span class="cd__n cd__n--2"><span class="cd__pop">2</span></span>
<span class="cd__n cd__n--1"><span class="cd__pop">1</span></span>
<span class="cd__tag">まもなく開始</span>
</div>
<button class="cd__skip" type="button" id="cdSkip">スキップ</button>
<p class="cd__sr" aria-live="polite" id="cdLive"></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 카운트다운 리드 — 3·2·1 이 steps(1, end) 로 한 프레임에 갈아 끼워진다(반투명 유령 프레임이 없다).
// 숫자는 컷으로만 바꾸고 움직임은 transform 이 맡는다: 숫자마다 scale 이 한 번씩 줄며 안착하는 팝,
// 그리고 셋을 세는 동안 카운트 전체가 scale 로 다가오는 dolly-in. 컷과 이징을 한 keyframes 에 섞지 않는다
// (steps 타이밍은 나머지 속성도 중간값 없이 점프시킨다 — 266-09 실측).
// steps(1, end) 는 구간 끝에서만 값이 바뀐다 — 컷 지점을 중간 키프레임(14·28·42%)으로 직접 찍는다.
// 대비(실측, run/016/_probe/대비_a2.py): 제목 $subject-cream/$stage-ink 17.11:1 ·
// 부제·태그 rgba($subject-cream,.72) 합성 #beb7ad 위 9.18:1 · 숫자 $stage-yellow/$stage-ink 12.63:1 ·
// CTA $subject-cream/$subject-blue-deep 5.98:1.
$duration: $dur-loop; // 미리보기 루프 주기 — 설정 초(2s)와 같다
$easing: $ease-pop; // 숫자 팝의 이징
$color: $stage-yellow; // 숫자색 — 잉크 무대 위
@include stage($stage-ink);
.stage { padding: 0; } // 인트로는 무대를 가득 채운다
.cd { position: relative; width: 100%; height: 100vh; }
// 첫 화면 — 카운트다운 뒤에 드러나는 본래 자리. 기본 상태가 곧 완료 상태다
.cd__site {
position: absolute; inset: 0; z-index: 1;
display: grid; place-items: center;
box-sizing: border-box; padding: $sp-4;
}
// 세로 묶음 — 등장 rise 는 화면보다 작은 이 상자에 건다(전면 요소를 밀면 scrollHeight 가 넘친다)
.cd__box {
display: flex; flex-direction: column; align-items: center;
width: min(84%, 400px); gap: $sp-2; text-align: center;
}
.cd__kicker {
margin: 0; color: rgba($subject-cream, .72);
font-weight: 700; font-size: 12px; line-height: 1.4; letter-spacing: .08em;
}
.cd__title {
margin: 0; color: $subject-cream;
font-weight: 800; font-size: clamp(24px, 7vw, 40px); line-height: 1.2; letter-spacing: -.01em;
}
.cd__sub {
margin: 0; color: rgba($subject-cream, .72);
font-weight: 500; font-size: clamp(13px, 3.4vw, 16px); line-height: 1.5;
}
.cd__cta {
margin-top: $sp-2; padding: $sp-2 $sp-5; border-radius: $r-pill;
background: $subject-blue-deep; color: $subject-cream; text-decoration: none;
font-weight: 700; font-size: 14px; line-height: 1.4;
}
.cd__cta:focus-visible { outline: 2px solid $subject-blue-light; outline-offset: 2px; }
// 카운트다운 숫자 — 세 숫자를 같은 칸에 포개 놓고 opacity 컷으로 하나씩만 보인다.
// 컨테이너는 내용 크기로 자랄 수 있게 중심점 고정 배치(dolly scale 이 화면을 넘기지 않게)
.cd__nums {
position: absolute; left: 50%; top: 50%; z-index: 2;
display: grid; justify-items: center; row-gap: $sp-2;
transform: translate(-50%, -50%);
pointer-events: none;
}
.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; // 기본(완료 상태)에는 숫자가 없다
}
.cd__pop { display: block; }
.cd__tag {
grid-row: 2;
margin: 0; color: rgba($subject-cream, .72);
font-weight: 700; font-size: 13px; line-height: 1.4; letter-spacing: .12em;
opacity: 0;
}
// 건너뛰기 — 숫자(잉크) 위에서도 첫 화면 위에서도 읽히도록 자기 배경을 가진 알약
.cd__skip {
position: absolute; top: $sp-2; right: $sp-2; z-index: 3;
padding: $sp-1 $sp-3; border: 1px solid rgba($subject-ink, .28); border-radius: $r-pill;
background: $stage-paper; color: $subject-ink; cursor: pointer;
font-family: inherit; font-weight: 600; font-size: 12px; line-height: 1.4;
}
.cd__skip:focus-visible { outline: 2px solid $subject-blue-light; outline-offset: 2px; }
// 화면 낭독기용 상태 한 줄 — 눈에는 보이지 않는다
.cd__sr {
position: absolute; width: 1px; height: 1px;
clip-path: inset(50%); overflow: hidden; white-space: nowrap;
}
// ── 격자 미리보기 루프 (2초). 컷 지점: 3 → 14% 에 2 → 28% 에 1 → 42% 에 첫 화면
// 숫자마다 팝을 새 keyframes 로 두는 이유 — delay 로 미루면 backwards 채움으로
// 아직 자기 차례가 아닌 숫자가 루프 앞구간에 보이기 때문이다.
.cd.is-demo .cd__n,
.cd.is-demo .cd__tag {
animation-duration: $duration;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
animation-fill-mode: both;
}
.cd.is-demo .cd__n--3 { animation-name: cdN3; }
.cd.is-demo .cd__n--2 { animation-name: cdN2; }
.cd.is-demo .cd__n--1 { animation-name: cdN1; }
.cd.is-demo .cd__tag { animation-name: cdTag; }
.cd.is-demo .cd__n--3 .cd__pop,
.cd.is-demo .cd__n--2 .cd__pop,
.cd.is-demo .cd__n--1 .cd__pop {
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
animation-fill-mode: both;
}
// 팝은 숫자마다 제 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; }
.cd.is-demo .cd__nums {
animation-name: cdDolly;
animation-duration: $duration;
animation-timing-function: $ease-out;
animation-iteration-count: infinite;
animation-fill-mode: both;
}
.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;
}
.cd.is-demo .cd__box {
animation-name: cdRise;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
animation-fill-mode: both;
}
// 숫자·태그 컷 — steps(1, end) 라 값은 구간 끝(각 컷 키프레임)에서만 바뀐다
@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; } }
@keyframes cdTag { 0% { opacity: 1; } 42% { opacity: 0; } 100% { opacity: 0; } }
@keyframes cdSite { 0% { opacity: 0; } 42% { opacity: 1; } 100% { opacity: 1; } }
// 숫자마다 한 번씩 줄어드는 팝 — 크게 들어와 제자리에 안착한다
@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); } }
// 셋을 세는 동안 화면이 가까워진다 — 중심 고정 translate 는 배치, scale 은 연출
@keyframes cdDolly {
0% { transform: translate(-50%, -50%) scale(1); }
42% { transform: translate(-50%, -50%) scale(1.12); }
100% { transform: translate(-50%, -50%) scale(1.12); }
}
// 첫 화면 등장 — 42% 컷 뒤 살짝 떠오른다
@keyframes cdRise {
0%, 42% { transform: translateY(12px); }
58% { transform: translateY(0); }
100% { transform: translateY(0); }
}
// ── 좁은 화면: 세로 여유(320×200)를 지키기 위해 묶음 간격과 글자를 줄인다
@media (max-width: 340px) {
.cd__site { padding: $sp-3; }
.cd__box { gap: $sp-1; }
.cd__title { font-size: 22px; }
.cd__sub { font-size: 12px; }
.cd__cta { margin-top: $sp-1; }
.cd__n { font-size: 72px; }
}
@media (prefers-reduced-motion: reduce) {
.cd.is-demo .cd__n,
.cd.is-demo .cd__tag,
.cd.is-demo .cd__pop,
.cd.is-demo .cd__nums,
.cd.is-demo .cd__site,
.cd.is-demo .cd__box {
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);
}
.stage {
padding: 0;
}
.cd {
position: relative;
width: 100%;
height: 100vh;
}
.cd__site {
position: absolute;
inset: 0;
z-index: 1;
display: grid;
place-items: center;
box-sizing: border-box;
padding: 16px;
}
.cd__box {
display: flex;
flex-direction: column;
align-items: center;
width: min(84%, 400px);
gap: 8px;
text-align: center;
}
.cd__kicker {
margin: 0;
color: rgba(255, 247, 230, 0.72);
font-weight: 700;
font-size: 12px;
line-height: 1.4;
letter-spacing: 0.08em;
}
.cd__title {
margin: 0;
color: #fff7e6;
font-weight: 800;
font-size: clamp(24px, 7vw, 40px);
line-height: 1.2;
letter-spacing: -0.01em;
}
.cd__sub {
margin: 0;
color: rgba(255, 247, 230, 0.72);
font-weight: 500;
font-size: clamp(13px, 3.4vw, 16px);
line-height: 1.5;
}
.cd__cta {
margin-top: 8px;
padding: 8px 20px;
border-radius: 999px;
background: #0a4fe8;
color: #fff7e6;
text-decoration: none;
font-weight: 700;
font-size: 14px;
line-height: 1.4;
}
.cd__cta:focus-visible {
outline: 2px solid #5b8cf8;
outline-offset: 2px;
}
.cd__nums {
position: absolute;
left: 50%;
top: 50%;
z-index: 2;
display: grid;
justify-items: center;
row-gap: 8px;
transform: translate(-50%, -50%);
pointer-events: none;
}
.cd__n {
grid-area: 1/1;
color: #ffd23f;
font-weight: 800;
font-size: clamp(72px, 24vw, 104px);
line-height: 1;
font-variant-numeric: tabular-nums;
opacity: 0;
}
.cd__pop {
display: block;
}
.cd__tag {
grid-row: 2;
margin: 0;
color: rgba(255, 247, 230, 0.72);
font-weight: 700;
font-size: 13px;
line-height: 1.4;
letter-spacing: 0.12em;
opacity: 0;
}
.cd__skip {
position: absolute;
top: 8px;
right: 8px;
z-index: 3;
padding: 4px 12px;
border: 1px solid rgba(23, 20, 26, 0.28);
border-radius: 999px;
background: #ffffff;
color: #17141a;
cursor: pointer;
font-family: inherit;
font-weight: 600;
font-size: 12px;
line-height: 1.4;
}
.cd__skip:focus-visible {
outline: 2px solid #5b8cf8;
outline-offset: 2px;
}
.cd__sr {
position: absolute;
width: 1px;
height: 1px;
clip-path: inset(50%);
overflow: hidden;
white-space: nowrap;
}
.cd.is-demo .cd__n,
.cd.is-demo .cd__tag {
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
animation-fill-mode: both;
}
.cd.is-demo .cd__n--3 {
animation-name: cdN3;
}
.cd.is-demo .cd__n--2 {
animation-name: cdN2;
}
.cd.is-demo .cd__n--1 {
animation-name: cdN1;
}
.cd.is-demo .cd__tag {
animation-name: cdTag;
}
.cd.is-demo .cd__n--3 .cd__pop,
.cd.is-demo .cd__n--2 .cd__pop,
.cd.is-demo .cd__n--1 .cd__pop {
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite;
animation-fill-mode: both;
}
.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;
}
.cd.is-demo .cd__nums {
animation-name: cdDolly;
animation-duration: 2s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
animation-fill-mode: both;
}
.cd.is-demo .cd__site {
animation-name: cdSite;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
animation-fill-mode: both;
}
.cd.is-demo .cd__box {
animation-name: cdRise;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite;
animation-fill-mode: both;
}
@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;
}
}
@keyframes cdTag {
0% {
opacity: 1;
}
42% {
opacity: 0;
}
100% {
opacity: 0;
}
}
@keyframes cdSite {
0% {
opacity: 0;
}
42% {
opacity: 1;
}
100% {
opacity: 1;
}
}
@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);
}
}
@keyframes cdDolly {
0% {
transform: translate(-50%, -50%) scale(1);
}
42% {
transform: translate(-50%, -50%) scale(1.12);
}
100% {
transform: translate(-50%, -50%) scale(1.12);
}
}
@keyframes cdRise {
0%, 42% {
transform: translateY(12px);
}
58% {
transform: translateY(0);
}
100% {
transform: translateY(0);
}
}
@media (max-width: 340px) {
.cd__site {
padding: 12px;
}
.cd__box {
gap: 4px;
}
.cd__title {
font-size: 22px;
}
.cd__sub {
font-size: 12px;
}
.cd__cta {
margin-top: 4px;
}
.cd__n {
font-size: 72px;
}
}
@media (prefers-reduced-motion: reduce) {
.cd.is-demo .cd__n,
.cd.is-demo .cd__tag,
.cd.is-demo .cd__pop,
.cd.is-demo .cd__nums,
.cd.is-demo .cd__site,
.cd.is-demo .cd__box {
animation: none !important;
}
}(function () {
var root = document.getElementById('cd');
var skipBtn = document.getElementById('cdSkip');
var live = document.getElementById('cdLive');
var KEY = 'intro05-seen';
// 스킵은 카운트다운을 건너뛰어 첫 화면(기본 CSS 상태)으로 바로 보내준다
function complete(msg) {
root.classList.remove('is-demo');
root.classList.add('is-done');
skipBtn.hidden = true;
live.textContent = msg;
try { sessionStorage.setItem(KEY, '1'); } catch (err) { /* 접근이 막힌 브라우저는 저장 없이 진행 */ }
}
skipBtn.addEventListener('click', function () { complete('イントロをスキップしました'); });
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape') complete('イントロをスキップしました');
});
// 한 번 본 세션은 처음부터 첫 화면으로 시작한다
try {
if (sessionStorage.getItem(KEY)) {
root.classList.remove('is-demo');
root.classList.add('is-done');
skipBtn.hidden = true;
live.textContent = '見済みのイントロのためすぐ開きました';
}
} catch (err) { /* sessionStorage 접근이 막히면 그냥 카운트다운을 보여준다 */ }
})();// CountdownLead — vanilla 판(run/016/demo/05-intro-countdown-lead/index.html) 의 React 포트.
// 3·2·1 이 steps(1, end) 컷으로 갈아 끼워지고 숫자마다 scale 팝·카운트 전체 dolly-in 이 따라온다.
// 스킵 버튼·Esc·sessionStorage 재방문 처리를 훅으로 옮겼고, @keyframes 본문은 vanilla 와 같은 글자다.
import { useEffect, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./CountdownLead.module.scss";
export type CountdownLeadProps = {
duration?: number; // ms — 미리보기 루프 주기
easing?: string; // 숫자 팝 이징
color?: string; // 숫자색
storageKey?: string; // sessionStorage 키 — 편마다 다르게
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type KeyEvt = { key: string };
const KICKER = "실시간 라이브";
const TITLE = "오늘 밤 아홉 시";
const SUB = "카운트다운이 끝나면 바로 입장합니다";
const CTA = "입장하기";
const TAG = "곧 시작합니다";
const SKIP = "건너뛰기";
const MSG_SKIP = "인트로를 건너뛰었습니다";
const MSG_AGAIN = "이미 본 인트로라 바로 열었습니다";
export default function CountdownLead(props: CountdownLeadProps) {
const duration = props.duration ?? 2000;
const easing = props.easing ?? "cubic-bezier(.2, 1.4, .4, 1)";
const color = props.color ?? "#ffd23f";
const key = props.storageKey ?? "intro05-seen";
const [msg, setMsg] = useState<string>("");
const [done, setDone] = useState<boolean>(() => {
try {
return !!sessionStorage.getItem(key);
} catch {
return false;
}
});
useEffect(() => {
try {
if (sessionStorage.getItem(key)) setMsg(MSG_AGAIN);
} catch {
/* sessionStorage 접근이 막히면 그냥 카운트다운을 보여준다 */
}
}, [key]);
function skipNow() {
setDone(true);
setMsg(MSG_SKIP);
try {
sessionStorage.setItem(key, "1");
} catch {
/* 접근이 막힌 브라우저는 저장 없이 진행 */
}
}
useEffect(() => {
function onKey(e: KeyEvt) {
if (e.key === "Escape") skipNow();
}
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [key]);
const vars: Vars = { "--duration": duration + "ms", "--easing": easing, "--color": color };
return (
<main className={styles.stage}>
<div className={styles.root + (done ? "" : " " + styles.isDemo)} style={vars}>
<div className={styles.site}>
<div className={styles.box}>
<p className={styles.kicker}>{KICKER}</p>
<h1 className={styles.title}>{TITLE}</h1>
<p className={styles.sub}>{SUB}</p>
<a className={styles.cta} href="#">{CTA}</a>
</div>
</div>
<div className={styles.nums} aria-hidden="true">
<span className={styles.n + " " + styles.n3}><span className={styles.pop}>3</span></span>
<span className={styles.n + " " + styles.n2}><span className={styles.pop}>2</span></span>
<span className={styles.n + " " + styles.n1}><span className={styles.pop}>1</span></span>
<span className={styles.tag}>{TAG}</span>
</div>
{!done && (
<button className={styles.skip} type="button" onClick={skipNow}>
{SKIP}
</button>
)}
<p className={styles.sr} aria-live="polite">{msg}</p>
</div>
</main>
);
}05 カウントダウン導入 — 計測 累積面積 10.317% · 動いたフレーム 14/23GODRICH オリジナル · SCSS と React は zip に同梱 · オープニング アニメーション css 9選 — イントロ アニメーション 入門
このデモが属する記事
オープニング アニメーション css 9選 — イントロ アニメーション 入門オープニング アニメーション css とは、ページが開いた最初の1〜2秒だけ画面を覆い、やがて退いて本来の画面を引き渡す演出のことです。スキップできてこそイントロ — 9つすべてにボタンと Esc と再訪の記録が備わり、その場で終わらせられ この記事のデモをすべて見る


