【コピペOK】カンファレンス LP セクション テンプレート9選
カンファレンス LP セクションテンプレートは、ヒーロー・スケジュール・登壇者紹介のようなイベントランディングページ用ブロックを、LPにそのまま貼れる形でまとめたコード集です。9つのうち1つは、実際の目標時刻まで秒単位で減っていくカウントダウンを収めています。
01 event-hero
02 event-schedule-timeline
03 event-speaker-grid
04 event-venue
05 event-ticket-pricing
06 event-sponsor-logo-bar
07 event-faq
08 event-register-cta
09 event-countdown
自動再生 · タイルを押すとその項目へ · すべて1つのzip
9つは人気順ではなく、来場者がランディングページを読み進める順番に並べました。まず目を引き(ヒーロー)、日程を確認させ(タイムライン)、信頼を積み上げ(登壇者)、アクセス情報を渡し(会場)、購入を決めさせ(料金)、信頼をもう一度補強し(スポンサー)、残った疑問を消し(FAQ)、行動に移させ(申込CTA)、最後に今すぐ動くべき理由を刻みます(カウントダウン)。写真やロゴの場所はすべてこのサイト共通の$subject-ink・$subject-blue・$subject-creamトークンで作った図形のプレースホルダーなので、実際の写真とロゴに差し替えるだけで使えます。催しが終わったあと報道をまとめておく場所が要るならプレスルームのセクションテンプレートが続き、参加状況を数字で見せる板はダッシュボードのウィジェットUIにあります。
01イベントヒーロー
イベント lp デザイン のヒーロー
背景で三つのぼかした円がずっと漂う中、図形だけのワードマーク・見出し・日付/会場のピル・CTAが一画面に収まります。

<main class="stage">
<section class="eh">
<span class="eh__orb eh__orb--1" aria-hidden="true"></span>
<span class="eh__orb eh__orb--2" aria-hidden="true"></span>
<span class="eh__orb eh__orb--3" aria-hidden="true"></span>
<span class="eh__mark" aria-hidden="true"></span>
<p class="eh__eyebrow">CONFERENCE — 2026</p>
<h1 class="eh__title">プロダクトを作る人たちの<br>一日</h1>
<div class="eh__meta">
<span class="eh__pill">10月18日</span>
<span class="eh__pill">ソウル・オフライン</span>
</div>
<div class="eh__row">
<a class="eh__cta" href="#">参加申込</a>
<button class="eh__save" type="button" aria-pressed="false">
<span class="eh__save-ic" aria-hidden="true"></span>
興味登録
</button>
</div>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-quick; // 관심 등록 버튼 전환
$easing: $ease-pop;
$color: $subject-blue; // CTA·아이콘 강조색
@include stage($stage-ink);
.eh {
position: relative;
box-sizing: border-box; // width 에 패딩을 포함 — 아니면 320px 에서 패딩만큼 넘친다(실측 2026-09-10)
width: min(400px, 92vw);
overflow: hidden;
border-radius: $r-card;
padding: $sp-3 $sp-5 $sp-2;
text-align: center;
isolation: isolate;
}
// ── 배경 원 세 개: 계속 떠다니는 장식(호버 아님, 무조건 재생) ──────────────
.eh__orb {
position: absolute;
border-radius: 50%;
filter: blur(18px);
opacity: .55;
z-index: -1;
}
.eh__orb--1 { width: 170px; height: 170px; left: -45px; top: -35px; background: radial-gradient(circle, $subject-blue, transparent 62%); opacity: 1; filter: blur(3px); animation: eh-float-a 2s ease-in-out infinite; }
.eh__orb--2 { width: 130px; height: 130px; right: -25px; top: 6px; background: radial-gradient(circle, $subject-cream, transparent 62%); opacity: .7; filter: blur(3px); animation: eh-float-b 2s ease-in-out infinite; }
.eh__orb--3 { width: 150px; height: 150px; left: 28%; bottom: -70px; background: radial-gradient(circle, $subject-blue, transparent 62%); opacity: .95; filter: blur(3px); animation: eh-float-a 2s ease-in-out infinite reverse; }
@keyframes eh-float-a {
0%, 100% { transform: translate(0, 0) scale(1); }
50% { transform: translate($sp-10, -$sp-8) scale(1.5); }
}
@keyframes eh-float-b {
0%, 100% { transform: translate(0, 0) scale(1); }
50% { transform: translate(-$sp-8, $sp-8) scale(.6); }
}
// ── 플레이스홀더 워드마크: 실제 로고가 아니라 도형 하나 ──────────────
.eh__mark {
display: block;
width: $sp-5; height: $sp-5;
margin: 0 auto $sp-1;
background: $color;
border-radius: $r-xs;
transform: rotate(45deg);
}
.eh__eyebrow {
margin: 0 0 $sp-1;
font-weight: 700; font-size: 10px; line-height: 1; font-family: inherit;
letter-spacing: .14em;
color: rgba(255, 247, 230, .6);
}
.eh__title {
margin: 0 0 $sp-1;
font-weight: 800; font-size: clamp(16px, 4.6vw, 19px); line-height: 1.14; font-family: inherit;
color: $subject-cream;
}
.eh__meta { display: flex; justify-content: center; gap: $sp-2; flex-wrap: wrap; margin-bottom: $sp-1; }
.eh__pill {
padding: $sp-1 $sp-2;
border-radius: $r-pill;
background: rgba(255, 247, 230, .1);
color: rgba(255, 247, 230, .82);
font-weight: 600; font-size: clamp(10px, 2.8vw, 11px); line-height: 1; font-family: inherit;
}
.eh__row { display: flex; justify-content: center; align-items: center; gap: $sp-2; flex-wrap: wrap; }
.eh__cta {
padding: $sp-2 $sp-5;
border-radius: $r-control;
background: $subject-blue-deep;
color: $subject-cream;
font-weight: 700; font-size: 12px; line-height: 1.4; font-family: inherit;
text-decoration: none;
}
.eh__save {
display: inline-flex; align-items: center; gap: $sp-1;
padding: $sp-2 $sp-3;
border-radius: $r-control;
border: 1px solid rgba(255, 247, 230, .3);
background: transparent;
color: rgba(255, 247, 230, .82);
font-weight: 600; font-size: 12px; line-height: 1.4; font-family: inherit;
cursor: pointer;
transition: background $duration $easing, border-color $duration $easing, transform $duration $easing;
}
.eh__save-ic {
width: $sp-3; height: $sp-3;
border-radius: 0;
background: currentColor;
clip-path: polygon(50% 0%, 63% 38%, 100% 38%, 70% 60%, 82% 100%, 50% 76%, 18% 100%, 30% 60%, 0% 38%, 37% 38%);
opacity: .5;
transition: opacity $duration $easing, transform $duration $easing;
}
.eh__save[aria-pressed="true"] {
background: rgba(255, 247, 230, .12);
border-color: $subject-cream;
}
.eh__save[aria-pressed="true"] .eh__save-ic {
opacity: 1;
color: $color;
transform: scale(1.15);
}
// ── 반응형: 320×200 처럼 세로가 짧은 화면에선 장식을 줄인다(BD9) ──────────────
@media (max-width: 340px), (max-height: 220px) {
.eh { padding: $sp-2 $sp-4; }
.eh__mark { display: none; }
.eh__eyebrow { display: none; }
.eh__title { font-size: 14px; line-height: 1.15; margin-bottom: $sp-1; }
.eh__meta { margin-bottom: $sp-1; }
.eh__pill { padding: $sp-1 $sp-2; font-size: 9px; }
.eh__row { gap: $sp-1; }
.eh__cta, .eh__save { padding: 4px $sp-3; font-size: 10px; }
}
// ── 접근성 ──────────────────────────────────────────
@media (prefers-reduced-motion: reduce) {
.eh__orb { animation: none !important; }
.eh__save, .eh__save-ic { transition: none; }
}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);
}
.eh {
position: relative;
box-sizing: border-box;
width: min(400px, 92vw);
overflow: hidden;
border-radius: 16px;
padding: 12px 20px 8px;
text-align: center;
isolation: isolate;
}
.eh__orb {
position: absolute;
border-radius: 50%;
filter: blur(18px);
opacity: 0.55;
z-index: -1;
}
.eh__orb--1 {
width: 170px;
height: 170px;
left: -45px;
top: -35px;
background: radial-gradient(circle, #2f6df6, transparent 62%);
opacity: 1;
filter: blur(3px);
animation: eh-float-a 2s ease-in-out infinite;
}
.eh__orb--2 {
width: 130px;
height: 130px;
right: -25px;
top: 6px;
background: radial-gradient(circle, #fff7e6, transparent 62%);
opacity: 0.7;
filter: blur(3px);
animation: eh-float-b 2s ease-in-out infinite;
}
.eh__orb--3 {
width: 150px;
height: 150px;
left: 28%;
bottom: -70px;
background: radial-gradient(circle, #2f6df6, transparent 62%);
opacity: 0.95;
filter: blur(3px);
animation: eh-float-a 2s ease-in-out infinite reverse;
}
@keyframes eh-float-a {
0%, 100% {
transform: translate(0, 0) scale(1);
}
50% {
transform: translate(40px, -32px) scale(1.5);
}
}
@keyframes eh-float-b {
0%, 100% {
transform: translate(0, 0) scale(1);
}
50% {
transform: translate(-32px, 32px) scale(0.6);
}
}
.eh__mark {
display: block;
width: 20px;
height: 20px;
margin: 0 auto 4px;
background: #2f6df6;
border-radius: 4px;
transform: rotate(45deg);
}
.eh__eyebrow {
margin: 0 0 4px;
font-weight: 700;
font-size: 10px;
line-height: 1;
font-family: inherit;
letter-spacing: 0.14em;
color: rgba(255, 247, 230, 0.6);
}
.eh__title {
margin: 0 0 4px;
font-weight: 800;
font-size: clamp(16px, 4.6vw, 19px);
line-height: 1.14;
font-family: inherit;
color: #fff7e6;
}
.eh__meta {
display: flex;
justify-content: center;
gap: 8px;
flex-wrap: wrap;
margin-bottom: 4px;
}
.eh__pill {
padding: 4px 8px;
border-radius: 999px;
background: rgba(255, 247, 230, 0.1);
color: rgba(255, 247, 230, 0.82);
font-weight: 600;
font-size: clamp(10px, 2.8vw, 11px);
line-height: 1;
font-family: inherit;
}
.eh__row {
display: flex;
justify-content: center;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.eh__cta {
padding: 8px 20px;
border-radius: 12px;
background: #0a4fe8;
color: #fff7e6;
font-weight: 700;
font-size: 12px;
line-height: 1.4;
font-family: inherit;
text-decoration: none;
}
.eh__save {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 8px 12px;
border-radius: 12px;
border: 1px solid rgba(255, 247, 230, 0.3);
background: transparent;
color: rgba(255, 247, 230, 0.82);
font-weight: 600;
font-size: 12px;
line-height: 1.4;
font-family: inherit;
cursor: pointer;
transition: background 220ms cubic-bezier(0.2, 1.4, 0.4, 1), border-color 220ms cubic-bezier(0.2, 1.4, 0.4, 1), transform 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.eh__save-ic {
width: 12px;
height: 12px;
border-radius: 0;
background: currentColor;
clip-path: polygon(50% 0%, 63% 38%, 100% 38%, 70% 60%, 82% 100%, 50% 76%, 18% 100%, 30% 60%, 0% 38%, 37% 38%);
opacity: 0.5;
transition: opacity 220ms cubic-bezier(0.2, 1.4, 0.4, 1), transform 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.eh__save[aria-pressed=true] {
background: rgba(255, 247, 230, 0.12);
border-color: #fff7e6;
}
.eh__save[aria-pressed=true] .eh__save-ic {
opacity: 1;
color: #2f6df6;
transform: scale(1.15);
}
@media (max-width: 340px), (max-height: 220px) {
.eh {
padding: 8px 16px;
}
.eh__mark {
display: none;
}
.eh__eyebrow {
display: none;
}
.eh__title {
font-size: 14px;
line-height: 1.15;
margin-bottom: 4px;
}
.eh__meta {
margin-bottom: 4px;
}
.eh__pill {
padding: 4px 8px;
font-size: 9px;
}
.eh__row {
gap: 4px;
}
.eh__cta, .eh__save {
padding: 4px 12px;
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.eh__orb {
animation: none !important;
}
.eh__save, .eh__save-ic {
transition: none;
}
}// 실제 동작: 클릭마다 興味登録 상태가 실제로 바뀐다(아이콘 채움 + aria-pressed).
var btn = document.querySelector('.eh__save');
btn.addEventListener('click', function () {
var on = btn.getAttribute('aria-pressed') === 'true';
btn.setAttribute('aria-pressed', String(!on));
});import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./EventHero.module.scss";
export type EventHeroProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function EventHero({ duration = 220, easing = "cubic-bezier(.2,1.4,.4,1)", color = "#2f6df6" }: EventHeroProps) {
const [saved, setSaved] = useState(false);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<section className={styles.eh} style={vars}>
<span className={`${styles.orb} ${styles.orb1}`} aria-hidden="true" />
<span className={`${styles.orb} ${styles.orb2}`} aria-hidden="true" />
<span className={styles.mark} aria-hidden="true" />
<p className={styles.eyebrow}>CONFERENCE — 2026</p>
<h1 className={styles.title}>제품을 만드는 사람들의 하루</h1>
<div className={styles.meta}>
<span className={styles.pill}>10월 18일</span>
<span className={styles.pill}>서울 · 오프라인</span>
</div>
<div className={styles.row}>
<a className={styles.cta} href="#">참가 신청</a>
<button
type="button"
className={styles.save}
aria-pressed={saved}
onClick={() => setSaved((v) => !v)}
>
<span className={styles.saveIc} aria-hidden="true" />
관심 등록
</button>
</div>
</section>
);
}01 · イベントヒーロー — 計測 累積面積 30.31% · 動いたフレーム 18/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
関心登録は本当に切り替わる
右の「関心登録」ボタンはクリックのたびにaria-pressedが実際に切り替わり、アイコンが塗りつぶされます。
.eh__orb--1 { animation: eh-float-a 2s ease-in-out infinite; }
@keyframes eh-float-a {
0%, 100% { transform: translate(0, 0) scale(1); }
50% { transform: translate($sp-10, -$sp-8) scale(1.5); }
}
.eh__save[aria-pressed="true"] {
background: rgba(255, 247, 230, .12);
border-color: $subject-cream;
}
.eh__save[aria-pressed="true"] .eh__save-ic {
opacity: 1;
color: $color;
transform: scale(1.15);
}
02スケジュールタイムライン
クリックした位置まで伸びる
縦線上の停車点(受付・基調講演・ネットワーキング・パネル)をクリックすると、その位置まで進捗バーがscaleYで実際に伸びます。

<main class="stage">
<section class="tl is-demo" data-active="0">
<span class="tl__track" aria-hidden="true"><span class="tl__fill"></span></span>
<ol class="tl__list">
<li><button class="tl__stop" type="button" data-i="0" aria-current="step">
<span class="tl__dot" aria-hidden="true"></span>
<span class="tl__time">09:00</span><span class="tl__label">受付・ウェルカムコーヒー</span>
</button></li>
<li><button class="tl__stop" type="button" data-i="1">
<span class="tl__dot" aria-hidden="true"></span>
<span class="tl__time">10:00</span><span class="tl__label">基調講演</span>
</button></li>
<li><button class="tl__stop" type="button" data-i="2">
<span class="tl__dot" aria-hidden="true"></span>
<span class="tl__time">13:00</span><span class="tl__label">ネットワーキングランチ</span>
</button></li>
<li><button class="tl__stop" type="button" data-i="3">
<span class="tl__dot" aria-hidden="true"></span>
<span class="tl__time">15:00</span><span class="tl__label">パネル討論</span>
</button></li>
</ol>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-base; // 진행바가 채워지는 시간
$easing: $ease-spring;
$color: $subject-ink; // 진행바·점 색
@include stage($stage-yellow);
.tl {
position: relative;
width: min(300px, 88vw);
padding-left: $sp-6;
}
.tl__track {
position: absolute;
left: 5px; top: $sp-1; bottom: $sp-1;
width: 2px;
background: rgba(23, 20, 26, .16);
border-radius: $r-pill;
overflow: hidden;
}
.tl__fill {
position: absolute; inset: 0;
transform: scaleY(.25);
transform-origin: top; // 규칙8: 타임라인 세로 채움 위에서
background: $color;
transition: transform $duration $easing;
}
.tl__list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: $sp-4; }
.tl__stop {
display: flex; align-items: center; gap: $sp-3;
background: none; border: 0; padding: 0;
text-align: left; cursor: pointer;
font: inherit; color: rgba($subject-ink, .72);
-webkit-tap-highlight-color: transparent;
}
.tl__dot {
width: $sp-2; height: $sp-2;
border-radius: 50%;
background: rgba(23, 20, 26, .3);
flex: 0 0 auto;
position: relative;
transition: background $duration $easing, transform $duration $easing;
}
.tl__time { font-weight: 700; font-size: clamp(11px, 3vw, 12px); line-height: 1; font-family: inherit; flex: 0 0 auto; }
.tl__label { font-weight: 600; font-size: clamp(12px, 3.4vw, 13px); line-height: 1.3; font-family: inherit; }
.tl__stop[aria-current="step"] { color: $subject-ink; }
.tl__stop[aria-current="step"] .tl__dot {
background: $color;
transform: scale(1.3);
box-shadow: 0 0 0 4px rgba(23, 20, 26, .14);
}
// ── 데모 루프: 진행바가 정류장 순서대로 흐르고, 그 순간의 점이 함께 펄스 ──────────────
.tl.is-demo .tl__fill { animation: tl-fill $dur-loop $easing infinite; }
@keyframes tl-fill {
0%, 8% { transform: scaleY(.25); }
25%, 33% { transform: scaleY(.5); }
50%, 58% { transform: scaleY(.75); }
75%, 83% { transform: scaleY(1); }
100% { transform: scaleY(.25); }
}
.tl.is-demo li:nth-child(1) .tl__dot { animation: tl-pulse $dur-loop $easing infinite; }
.tl.is-demo li:nth-child(2) .tl__dot { animation: tl-pulse $dur-loop $easing infinite; animation-delay: calc($dur-loop * .25); }
.tl.is-demo li:nth-child(3) .tl__dot { animation: tl-pulse $dur-loop $easing infinite; animation-delay: calc($dur-loop * .5); }
.tl.is-demo li:nth-child(4) .tl__dot { animation: tl-pulse $dur-loop $easing infinite; animation-delay: calc($dur-loop * .75); }
@keyframes tl-pulse {
0%, 92%, 100% { background: rgba(23, 20, 26, .3); transform: scale(1); box-shadow: 0 0 0 0 rgba(23, 20, 26, 0); }
4% { background: $color; transform: scale(1.3); box-shadow: 0 0 0 4px rgba(23, 20, 26, .14); }
20% { background: $color; transform: scale(1.3); box-shadow: 0 0 0 4px rgba(23, 20, 26, .14); }
}
// ── 접근성 ──────────────────────────────────────────
@media (prefers-reduced-motion: reduce) {
.tl__fill, .tl__dot { transition: none; }
.tl.is-demo .tl__fill,
.tl.is-demo .tl__stop .tl__dot { 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: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.tl {
position: relative;
width: min(300px, 88vw);
padding-left: 24px;
}
.tl__track {
position: absolute;
left: 5px;
top: 4px;
bottom: 4px;
width: 2px;
background: rgba(23, 20, 26, 0.16);
border-radius: 999px;
overflow: hidden;
}
.tl__fill {
position: absolute;
inset: 0;
transform: scaleY(0.25);
transform-origin: top;
background: #17141a;
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.tl__list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 16px;
}
.tl__stop {
display: flex;
align-items: center;
gap: 12px;
background: none;
border: 0;
padding: 0;
text-align: left;
cursor: pointer;
font: inherit;
color: rgba(23, 20, 26, 0.72);
-webkit-tap-highlight-color: transparent;
}
.tl__dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: rgba(23, 20, 26, 0.3);
flex: 0 0 auto;
position: relative;
transition: background 300ms cubic-bezier(0.2, 0.8, 0.2, 1), transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.tl__time {
font-weight: 700;
font-size: clamp(11px, 3vw, 12px);
line-height: 1;
font-family: inherit;
flex: 0 0 auto;
}
.tl__label {
font-weight: 600;
font-size: clamp(12px, 3.4vw, 13px);
line-height: 1.3;
font-family: inherit;
}
.tl__stop[aria-current=step] {
color: #17141a;
}
.tl__stop[aria-current=step] .tl__dot {
background: #17141a;
transform: scale(1.3);
box-shadow: 0 0 0 4px rgba(23, 20, 26, 0.14);
}
.tl.is-demo .tl__fill {
animation: tl-fill 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes tl-fill {
0%, 8% {
transform: scaleY(0.25);
}
25%, 33% {
transform: scaleY(0.5);
}
50%, 58% {
transform: scaleY(0.75);
}
75%, 83% {
transform: scaleY(1);
}
100% {
transform: scaleY(0.25);
}
}
.tl.is-demo li:nth-child(1) .tl__dot {
animation: tl-pulse 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.tl.is-demo li:nth-child(2) .tl__dot {
animation: tl-pulse 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
animation-delay: 0.5s;
}
.tl.is-demo li:nth-child(3) .tl__dot {
animation: tl-pulse 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
animation-delay: 1s;
}
.tl.is-demo li:nth-child(4) .tl__dot {
animation: tl-pulse 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
animation-delay: 1.5s;
}
@keyframes tl-pulse {
0%, 92%, 100% {
background: rgba(23, 20, 26, 0.3);
transform: scale(1);
box-shadow: 0 0 0 0 rgba(23, 20, 26, 0);
}
4% {
background: #17141a;
transform: scale(1.3);
box-shadow: 0 0 0 4px rgba(23, 20, 26, 0.14);
}
20% {
background: #17141a;
transform: scale(1.3);
box-shadow: 0 0 0 4px rgba(23, 20, 26, 0.14);
}
}
@media (prefers-reduced-motion: reduce) {
.tl__fill, .tl__dot {
transition: none;
}
.tl.is-demo .tl__fill,
.tl.is-demo .tl__stop .tl__dot {
animation: none !important;
}
}// 실제 동작: 정류장을 클릭하면 진행바가 그 지점까지 실제로 채워지고 aria-current 가 옮겨간다.
var root = document.querySelector('.tl');
var stops = root.querySelectorAll('.tl__stop');
stops.forEach(function (btn) {
btn.addEventListener('click', function () {
root.classList.remove('is-demo');
var i = btn.getAttribute('data-i');
root.setAttribute('data-active', i);
root.querySelector('.tl__fill').style.transform = 'scaleY(' + ((Number(i) + 1) / 4) + ')';
stops.forEach(function (s) { s.removeAttribute('aria-current'); });
btn.setAttribute('aria-current', 'step');
});
});import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./ScheduleTimeline.module.scss";
const STOPS = [
{ time: "09:00", label: "등록·웰컴 커피" },
{ time: "10:00", label: "기조연설" },
{ time: "13:00", label: "네트워킹 런치" },
{ time: "15:00", label: "패널 토론" },
];
export type ScheduleTimelineProps = { duration?: number; easing?: string; color?: string };
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function ScheduleTimeline({ duration = 300, easing = "cubic-bezier(.2,.8,.2,1)", color = "#17141a" }: ScheduleTimelineProps) {
const [active, setActive] = useState(0);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<section className={styles.tl} style={vars}>
<span className={styles.track} aria-hidden="true">
<span className={styles.fill} style={{ transform: `scaleY(${(active + 1) / STOPS.length})` }} />
</span>
<ol className={styles.list}>
{STOPS.map((s, i) => (
<li key={s.time}>
<button
type="button"
className={styles.stop}
aria-current={i === active ? "step" : undefined}
onClick={() => setActive(i)}
>
<span className={styles.dot} aria-hidden="true" />
<span className={styles.time}>{s.time}</span>
<span className={styles.label}>{s.label}</span>
</button>
</li>
))}
</ol>
</section>
);
}02 · スケジュールタイムライン — 計測 累積面積 1.099% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
選んだ点だけを強調
選んだ停車点の点だけがパルスリングで強調されます。
.tl__fill {
position: absolute; inset: 0;
transform: scaleY(.25);
transform-origin: top;
background: $color;
transition: transform $duration $easing;
}
.tl__stop[aria-current="step"] .tl__dot {
background: $color;
transform: scale(1.3);
box-shadow: 0 0 0 4px rgba(23, 20, 26, .14);
}
03登壇者グリッド
6枚が順に起き上がる
カード6枚が順番にわずかに傾いてから起き上がるように現れます。

<main class="stage">
<ul class="sg is-demo">
<li class="sg__card"><button class="sg__toggle" type="button" aria-expanded="false">
<span class="sg__avatar" aria-hidden="true">キム・ミ</span>
<span class="sg__name">キム・ミンジュン</span>
<span class="sg__role">プロダクトデザイナー・○○チーム</span>
</button><p class="sg__bio">「説明のいらない画面」の作り方を発表します。</p></li>
<li class="sg__card"><button class="sg__toggle" type="button" aria-expanded="false">
<span class="sg__avatar" aria-hidden="true">イ・ソ</span>
<span class="sg__name">イ・ソヨン</span>
<span class="sg__role">エンジニアリングリード・○○スタジオ</span>
</button><p class="sg__bio">早く作り、手遅れになる前に戻す、デプロイの話。</p></li>
<li class="sg__card"><button class="sg__toggle" type="button" aria-expanded="false">
<span class="sg__avatar" aria-hidden="true">パク・ド</span>
<span class="sg__name">パク・ドユン</span>
<span class="sg__role">グロースマネージャー・○○コマース</span>
</button><p class="sg__bio">数字より先に見るべきユーザーシグナル3つ。</p></li>
<li class="sg__card"><button class="sg__toggle" type="button" aria-expanded="false">
<span class="sg__avatar" aria-hidden="true">チェ・ユ</span>
<span class="sg__name">チェ・ユナ</span>
<span class="sg__role">リサーチャー・○○ラボ</span>
</button><p class="sg__bio">10回のインタビューで見えるもの、100回で見えるもの。</p></li>
<li class="sg__card"><button class="sg__toggle" type="button" aria-expanded="false">
<span class="sg__avatar" aria-hidden="true">ジョン・ハ</span>
<span class="sg__name">ジョン・ハウン</span>
<span class="sg__role">創業者・○○</span>
</button><p class="sg__bio">3回失敗して学んだことだけ、短く話します。</p></li>
<li class="sg__card"><button class="sg__toggle" type="button" aria-expanded="false">
<span class="sg__avatar" aria-hidden="true">ハン・ジ</span>
<span class="sg__name">ハン・ジホ</span>
<span class="sg__role">プラットフォームエンジニア・○○</span>
</button><p class="sg__bio">トラフィックが10倍になった時、最初に落ちる場所は。</p></li>
</ul>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-enter; // 카드 들어오는 시간
$easing: $ease-pop;
$color: $subject-ink; // 아바타 배경색
@include stage($stage-orange);
.sg {
list-style: none; margin: 0; padding: 0;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: $sp-3;
width: min(420px, 94vw);
}
@media (max-width: 340px), (max-height: 220px) {
// 2열로 바꾸면 6장이 3행이 돼 더 길어진다 — 3열을 유지하고 카드 안쪽만 줄인다
.sg { gap: $sp-1; }
.sg__toggle { padding: $sp-1; gap: $sp-1; }
.sg__avatar { width: $sp-5; height: $sp-5; font-size: 9px; }
.sg__role { display: none; }
}
.sg__card {
background: $subject-cream;
border-radius: $r-card;
overflow: hidden;
}
.sg__toggle {
display: flex; flex-direction: column; align-items: center; gap: $sp-1;
width: 100%;
padding: $sp-2;
border: 0; background: none; cursor: pointer;
font: inherit; text-align: center;
-webkit-tap-highlight-color: transparent;
}
.sg__avatar {
width: $sp-6; height: $sp-6;
display: grid; place-items: center;
border-radius: 50%;
background: $color;
color: $subject-cream;
font-weight: 700; font-size: 10px; line-height: 1; font-family: inherit;
}
.sg__name { font-weight: 700; font-size: clamp(11px, 3vw, 12px); line-height: 1.2; font-family: inherit; color: $subject-ink; }
.sg__role { font-weight: 500; font-size: 9px; line-height: 1.3; font-family: inherit; color: rgba($subject-ink, .72); }
.sg__bio {
margin: 0;
padding: 0 $sp-2;
max-height: 0;
overflow: hidden;
opacity: 0;
font-weight: 500; font-size: 9px; line-height: 1.4; font-family: inherit;
color: rgba(23, 20, 26, .7);
transition: max-height $duration $easing, opacity $duration $easing, padding-bottom $duration $easing;
}
.sg__card.is-open .sg__bio {
max-height: 60px;
opacity: 1;
padding-bottom: $sp-3;
}
// ── 데모 루프: 카드가 순서대로 살짝 기울며 떠오르고, 두 번째 카드는 소개까지 펼쳤다 접는다 ──────────────
.sg.is-demo .sg__card { animation: sg-in $dur-loop $easing infinite; }
.sg.is-demo .sg__card:nth-child(1) { animation-delay: 0ms; }
.sg.is-demo .sg__card:nth-child(2) { animation-delay: 90ms; }
.sg.is-demo .sg__card:nth-child(3) { animation-delay: 180ms; }
.sg.is-demo .sg__card:nth-child(4) { animation-delay: 270ms; }
.sg.is-demo .sg__card:nth-child(5) { animation-delay: 360ms; }
.sg.is-demo .sg__card:nth-child(6) { animation-delay: 450ms; }
@keyframes sg-in {
0% { opacity: .7; transform: translateY(6px) rotate(-1.5deg); }
18% { opacity: 1; transform: translateY(0) rotate(0deg); }
86% { opacity: 1; transform: translateY(0) rotate(0deg); }
100% { opacity: .7; transform: translateY(6px) rotate(-1.5deg); }
}
// ── 접근성 ──────────────────────────────────────────
@media (prefers-reduced-motion: reduce) {
.sg__bio { transition: none; }
.sg.is-demo .sg__card,
.sg.is-demo .sg__card .sg__bio { 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);
}
.sg {
list-style: none;
margin: 0;
padding: 0;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
width: min(420px, 94vw);
}
@media (max-width: 340px), (max-height: 220px) {
.sg {
gap: 4px;
}
.sg__toggle {
padding: 4px;
gap: 4px;
}
.sg__avatar {
width: 20px;
height: 20px;
font-size: 9px;
}
.sg__role {
display: none;
}
}
.sg__card {
background: #fff7e6;
border-radius: 16px;
overflow: hidden;
}
.sg__toggle {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
width: 100%;
padding: 8px;
border: 0;
background: none;
cursor: pointer;
font: inherit;
text-align: center;
-webkit-tap-highlight-color: transparent;
}
.sg__avatar {
width: 24px;
height: 24px;
display: grid;
place-items: center;
border-radius: 50%;
background: #17141a;
color: #fff7e6;
font-weight: 700;
font-size: 10px;
line-height: 1;
font-family: inherit;
}
.sg__name {
font-weight: 700;
font-size: clamp(11px, 3vw, 12px);
line-height: 1.2;
font-family: inherit;
color: #17141a;
}
.sg__role {
font-weight: 500;
font-size: 9px;
line-height: 1.3;
font-family: inherit;
color: rgba(23, 20, 26, 0.72);
}
.sg__bio {
margin: 0;
padding: 0 8px;
max-height: 0;
overflow: hidden;
opacity: 0;
font-weight: 500;
font-size: 9px;
line-height: 1.4;
font-family: inherit;
color: rgba(23, 20, 26, 0.7);
transition: max-height 600ms cubic-bezier(0.2, 1.4, 0.4, 1), opacity 600ms cubic-bezier(0.2, 1.4, 0.4, 1), padding-bottom 600ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.sg__card.is-open .sg__bio {
max-height: 60px;
opacity: 1;
padding-bottom: 12px;
}
.sg.is-demo .sg__card {
animation: sg-in 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
.sg.is-demo .sg__card:nth-child(1) {
animation-delay: 0ms;
}
.sg.is-demo .sg__card:nth-child(2) {
animation-delay: 90ms;
}
.sg.is-demo .sg__card:nth-child(3) {
animation-delay: 180ms;
}
.sg.is-demo .sg__card:nth-child(4) {
animation-delay: 270ms;
}
.sg.is-demo .sg__card:nth-child(5) {
animation-delay: 360ms;
}
.sg.is-demo .sg__card:nth-child(6) {
animation-delay: 450ms;
}
@keyframes sg-in {
0% {
opacity: 0.7;
transform: translateY(6px) rotate(-1.5deg);
}
18% {
opacity: 1;
transform: translateY(0) rotate(0deg);
}
86% {
opacity: 1;
transform: translateY(0) rotate(0deg);
}
100% {
opacity: 0.7;
transform: translateY(6px) rotate(-1.5deg);
}
}
@media (prefers-reduced-motion: reduce) {
.sg__bio {
transition: none;
}
.sg.is-demo .sg__card,
.sg.is-demo .sg__card .sg__bio {
animation: none !important;
}
}// 실제 동작: 카드를 클릭하면 그 카드만 한 줄 소개가 실제로 펼쳐진다.
document.querySelectorAll('.sg__toggle').forEach(function (btn) {
btn.addEventListener('click', function () {
document.querySelector('.sg').classList.remove('is-demo');
var open = btn.getAttribute('aria-expanded') === 'true';
btn.setAttribute('aria-expanded', String(!open));
btn.closest('.sg__card').classList.toggle('is-open', !open);
});
});import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./SpeakerGrid.module.scss";
const SPEAKERS = [
{ initials: "김민", name: "김민준", role: "프로덕트 디자이너 · ○○팀", bio: "\"설명이 필요 없는 화면\"을 만드는 법을 발표합니다." },
{ initials: "이서", name: "이서연", role: "엔지니어링 리드 · ○○ 스튜디오", bio: "빠르게 만들고 늦지 않게 되돌리는 배포 이야기." },
{ initials: "박도", name: "박도윤", role: "그로스 매니저 · ○○ 커머스", bio: "숫자보다 먼저 봐야 하는 사용자 신호 세 가지." },
{ initials: "최유", name: "최유나", role: "리서처 · ○○ 랩", bio: "인터뷰 열 번이면 보이는 것, 백 번이면 보이는 것." },
{ initials: "정하", name: "정하은", role: "창업자 · ○○", bio: "세 번 실패하고 배운 것만 골라 짧게 말합니다." },
{ initials: "한지", name: "한지호", role: "플랫폼 엔지니어 · ○○", bio: "트래픽 열 배가 몰릴 때 먼저 죽는 곳은 어디인가." },
];
export type SpeakerGridProps = { duration?: number; easing?: string; color?: string };
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function SpeakerGrid({ duration = 600, easing = "cubic-bezier(.2,1.4,.4,1)", color = "#17141a" }: SpeakerGridProps) {
const [open, setOpen] = useState<number | null>(null);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<ul className={styles.sg} style={vars}>
{SPEAKERS.map((s, i) => (
<li key={s.name} className={styles.card}>
<button
type="button"
className={styles.toggle}
aria-expanded={open === i}
onClick={() => setOpen((o) => (o === i ? null : i))}
>
<span className={styles.avatar} aria-hidden="true">{s.initials}</span>
<span className={styles.name}>{s.name}</span>
<span className={styles.role}>{s.role}</span>
</button>
<p className={styles.bio} style={{ maxHeight: open === i ? 60 : 0, opacity: open === i ? 1 : 0 }}>{s.bio}</p>
</li>
))}
</ul>
);
}03 · 登壇者グリッド — 計測 累積面積 40.452% · 動いたフレーム 13/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
開くのは1枚だけ
カードをクリックするとそのカードだけ一行の紹介文がmax-height遷移で開き、他は閉じたままです。
.sg.is-demo .sg__card { animation: sg-in 2s $easing infinite; }
@keyframes sg-in {
0% { opacity: .7; transform: translateY(6px) rotate(-1.5deg); }
18% { opacity: 1; transform: translateY(0) rotate(0deg); }
86% { opacity: 1; transform: translateY(0) rotate(0deg); }
100% { opacity: .7; transform: translateY(6px) rotate(-1.5deg); }
}
.sg__card.is-open .sg__bio { max-height: 60px; opacity: 1; }
04会場案内
タブで移動手段を選ぶ
地下鉄・バス・車のタブを押すと、その移動手段の説明だけが実際に切り替わり、下線インジケーターが選んだタブの下まで滑ります。

<main class="stage">
<section class="vn is-demo">
<div class="vn__info">
<p class="vn__name">○○コンベンションセンター 3階</p>
<p class="vn__addr">ソウル江南区○○路123</p>
<div class="vn__tabs" role="tablist">
<button class="vn__tab" type="button" role="tab" aria-selected="true" data-i="0">地下鉄</button>
<button class="vn__tab" type="button" role="tab" aria-selected="false" data-i="1">バス</button>
<button class="vn__tab" type="button" role="tab" aria-selected="false" data-i="2">車</button>
<span class="vn__ind" aria-hidden="true"></span>
</div>
<div class="vn__panels">
<p class="vn__panel vn__panel--0">2号線○○駅3番出口、徒歩5分。</p>
<p class="vn__panel vn__panel--1">幹線000・支線0000番、○○駅下車。</p>
<p class="vn__panel vn__panel--2">地下駐車場2時間無料、以降1時間2,000ウォン。</p>
</div>
</div>
<div class="vn__map" aria-hidden="true">
<span class="vn__pin"></span>
</div>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-base; // 탭 인디케이터·패널 전환
$easing: $ease-spring;
$color: $subject-blue; // 인디케이터·핀 색
@include stage($stage-paper);
.vn {
display: grid;
grid-template-columns: 1.1fr 1fr;
gap: $sp-4;
width: min(420px, 94vw);
align-items: stretch;
}
@media (max-width: 340px) {
.vn { grid-template-columns: 1fr; }
.vn__map { display: none; }
.vn__name { margin-bottom: $sp-1; font-size: 12px; }
.vn__addr { margin-bottom: $sp-2; font-size: 10px; }
.vn__tabs { margin-bottom: $sp-2; }
.vn__tab { padding: $sp-1 0; font-size: 10px; }
.vn__panel { font-size: 10px; }
}
.vn__name { margin: 0 0 $sp-1; font-weight: 700; font-size: clamp(12px, 3.4vw, 13px); line-height: 1.3; font-family: inherit; color: $subject-ink; }
.vn__addr { margin: 0 0 $sp-3; font-weight: 500; font-size: 11px; line-height: 1.4; font-family: inherit; color: rgba(23, 20, 26, .6); }
.vn__tabs {
position: relative;
display: flex;
border-bottom: 1px solid rgba(23, 20, 26, .12);
margin-bottom: $sp-3;
}
.vn__tab {
flex: 1 1 0;
padding: $sp-2 0;
background: none; border: 0; cursor: pointer;
font-weight: 600; font-size: 11px; line-height: 1; font-family: inherit;
color: rgba($subject-ink, .72);
-webkit-tap-highlight-color: transparent;
}
.vn__tab[aria-selected="true"] { color: $subject-ink; }
.vn__ind {
position: absolute; left: 0; bottom: -1px;
width: calc(100% / 3); height: 2px;
background: $color;
transform: translateX(0%);
transition: transform $duration $easing;
}
.vn__panels { position: relative; display: grid; min-height: 34px; }
.vn__panel {
grid-area: 1 / 1;
margin: 0;
font-weight: 500; font-size: 11px; line-height: 1.5; font-family: inherit;
color: rgba(23, 20, 26, .75);
opacity: 0;
transition: opacity $duration $easing;
}
.vn__panel--0 { opacity: 1; }
.vn__map {
position: relative;
border-radius: $r-card;
background:
linear-gradient(rgba(23, 20, 26, .08) 1px, transparent 1px) 0 0 / 20px 20px,
linear-gradient(90deg, rgba(23, 20, 26, .08) 1px, transparent 1px) 0 0 / 20px 20px,
$subject-cream;
min-height: 120px;
}
.vn__pin {
position: absolute; left: 46%; top: 42%;
width: $sp-3; height: $sp-3;
border-radius: 50%;
background: $color;
box-shadow: 0 0 0 0 rgba(47, 109, 246, .5);
animation: vn-ping 2s ease-out infinite;
}
@keyframes vn-ping {
0% { box-shadow: 0 0 0 0 rgba(47, 109, 246, .5); }
70% { box-shadow: 0 0 0 14px rgba(47, 109, 246, 0); }
100% { box-shadow: 0 0 0 14px rgba(47, 109, 246, 0); }
}
// ── 데모 루프: 클릭 전까지 탭 세 개를 스스로 오간다 ──────────────
.vn.is-demo .vn__ind { animation: vn-loop $dur-loop $easing infinite; }
.vn.is-demo .vn__panel--0 { animation: vn-panel-0 $dur-loop $easing infinite; }
.vn.is-demo .vn__panel--1 { animation: vn-panel-1 $dur-loop $easing infinite; }
.vn.is-demo .vn__panel--2 { animation: vn-panel-2 $dur-loop $easing infinite; }
@keyframes vn-loop {
0%, 15% { transform: translateX(0%); }
33%, 48% { transform: translateX(100%); }
66%, 81% { transform: translateX(200%); }
100% { transform: translateX(0%); }
}
@keyframes vn-panel-0 { 0%, 15% { opacity: 1; } 30%, 100% { opacity: 0; } }
@keyframes vn-panel-1 { 0%, 30% { opacity: 0; } 45%, 63% { opacity: 1; } 78%, 100% { opacity: 0; } }
@keyframes vn-panel-2 { 0%, 63% { opacity: 0; } 78%, 96% { opacity: 1; } 100% { opacity: 0; } }
// ── 접근성 ──────────────────────────────────────────
@media (prefers-reduced-motion: reduce) {
.vn__ind, .vn__panel { transition: none; }
.vn__pin { animation: none; }
.vn.is-demo .vn__ind,
.vn.is-demo .vn__panel--0,
.vn.is-demo .vn__panel--1,
.vn.is-demo .vn__panel--2 { 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: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.vn {
display: grid;
grid-template-columns: 1.1fr 1fr;
gap: 16px;
width: min(420px, 94vw);
align-items: stretch;
}
@media (max-width: 340px) {
.vn {
grid-template-columns: 1fr;
}
.vn__map {
display: none;
}
.vn__name {
margin-bottom: 4px;
font-size: 12px;
}
.vn__addr {
margin-bottom: 8px;
font-size: 10px;
}
.vn__tabs {
margin-bottom: 8px;
}
.vn__tab {
padding: 4px 0;
font-size: 10px;
}
.vn__panel {
font-size: 10px;
}
}
.vn__name {
margin: 0 0 4px;
font-weight: 700;
font-size: clamp(12px, 3.4vw, 13px);
line-height: 1.3;
font-family: inherit;
color: #17141a;
}
.vn__addr {
margin: 0 0 12px;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
font-family: inherit;
color: rgba(23, 20, 26, 0.6);
}
.vn__tabs {
position: relative;
display: flex;
border-bottom: 1px solid rgba(23, 20, 26, 0.12);
margin-bottom: 12px;
}
.vn__tab {
flex: 1 1 0;
padding: 8px 0;
background: none;
border: 0;
cursor: pointer;
font-weight: 600;
font-size: 11px;
line-height: 1;
font-family: inherit;
color: rgba(23, 20, 26, 0.72);
-webkit-tap-highlight-color: transparent;
}
.vn__tab[aria-selected=true] {
color: #17141a;
}
.vn__ind {
position: absolute;
left: 0;
bottom: -1px;
width: 33.3333333333%;
height: 2px;
background: #2f6df6;
transform: translateX(0%);
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.vn__panels {
position: relative;
display: grid;
min-height: 34px;
}
.vn__panel {
grid-area: 1/1;
margin: 0;
font-weight: 500;
font-size: 11px;
line-height: 1.5;
font-family: inherit;
color: rgba(23, 20, 26, 0.75);
opacity: 0;
transition: opacity 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.vn__panel--0 {
opacity: 1;
}
.vn__map {
position: relative;
border-radius: 16px;
background: linear-gradient(rgba(23, 20, 26, 0.08) 1px, transparent 1px) 0 0/20px 20px, linear-gradient(90deg, rgba(23, 20, 26, 0.08) 1px, transparent 1px) 0 0/20px 20px, #fff7e6;
min-height: 120px;
}
.vn__pin {
position: absolute;
left: 46%;
top: 42%;
width: 12px;
height: 12px;
border-radius: 50%;
background: #2f6df6;
box-shadow: 0 0 0 0 rgba(47, 109, 246, 0.5);
animation: vn-ping 2s ease-out infinite;
}
@keyframes vn-ping {
0% {
box-shadow: 0 0 0 0 rgba(47, 109, 246, 0.5);
}
70% {
box-shadow: 0 0 0 14px rgba(47, 109, 246, 0);
}
100% {
box-shadow: 0 0 0 14px rgba(47, 109, 246, 0);
}
}
.vn.is-demo .vn__ind {
animation: vn-loop 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.vn.is-demo .vn__panel--0 {
animation: vn-panel-0 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.vn.is-demo .vn__panel--1 {
animation: vn-panel-1 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.vn.is-demo .vn__panel--2 {
animation: vn-panel-2 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes vn-loop {
0%, 15% {
transform: translateX(0%);
}
33%, 48% {
transform: translateX(100%);
}
66%, 81% {
transform: translateX(200%);
}
100% {
transform: translateX(0%);
}
}
@keyframes vn-panel-0 {
0%, 15% {
opacity: 1;
}
30%, 100% {
opacity: 0;
}
}
@keyframes vn-panel-1 {
0%, 30% {
opacity: 0;
}
45%, 63% {
opacity: 1;
}
78%, 100% {
opacity: 0;
}
}
@keyframes vn-panel-2 {
0%, 63% {
opacity: 0;
}
78%, 96% {
opacity: 1;
}
100% {
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.vn__ind, .vn__panel {
transition: none;
}
.vn__pin {
animation: none;
}
.vn.is-demo .vn__ind,
.vn.is-demo .vn__panel--0,
.vn.is-demo .vn__panel--1,
.vn.is-demo .vn__panel--2 {
animation: none !important;
}
}// 실제 동작: 탭을 클릭하면 그 이동수단 설명만 실제로 보인다.
var root = document.querySelector('.vn');
var tabs = root.querySelectorAll('.vn__tab');
tabs.forEach(function (t) {
t.addEventListener('click', function () {
root.classList.remove('is-demo');
tabs.forEach(function (o) { o.setAttribute('aria-selected', 'false'); });
t.setAttribute('aria-selected', 'true');
root.querySelector('.vn__ind').style.transform = 'translateX(' + (Number(t.dataset.i) * 100) + '%)';
root.querySelectorAll('.vn__panel').forEach(function (p, i) {
p.style.opacity = String(i === Number(t.dataset.i) ? 1 : 0);
});
});
});import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./VenueBlock.module.scss";
const DIRECTIONS = [
{ label: "지하철", text: "2호선 ○○역 3번 출구, 도보 5분." },
{ label: "버스", text: "간선 000·지선 0000번, ○○역 하차." },
{ label: "자차", text: "지하 주차장 2시간 무료, 이후 시간당 2천 원." },
];
export type VenueBlockProps = { duration?: number; easing?: string; color?: string };
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function VenueBlock({ duration = 300, easing = "cubic-bezier(.2,.8,.2,1)", color = "#2f6df6" }: VenueBlockProps) {
const [active, setActive] = useState(0);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<section className={styles.vn} style={vars}>
<div>
<p className={styles.name}>○○ 컨벤션센터 3층</p>
<p className={styles.addr}>서울 강남구 ○○로 123</p>
<div className={styles.tabs} role="tablist">
{DIRECTIONS.map((d, i) => (
<button
key={d.label}
type="button"
role="tab"
aria-selected={i === active}
className={styles.tab}
onClick={() => setActive(i)}
>
{d.label}
</button>
))}
<span className={styles.ind} style={{ transform: `translateX(${active * 100}%)` }} aria-hidden="true" />
</div>
<p className={styles.panel}>{DIRECTIONS[active].text}</p>
</div>
<div className={styles.map} aria-hidden="true">
<span className={styles.pin} />
</div>
</section>
);
}04 · 会場案内 — 計測 累積面積 2.01% · 動いたフレーム 20/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
ピンは波紋を描き続ける
地図プレースホルダーの上のピンは常に波紋を描き続けます。
tabs.forEach(function (t) {
t.addEventListener('click', function () {
tabs.forEach(function (o) { o.setAttribute('aria-selected', 'false'); });
t.setAttribute('aria-selected', 'true');
ind.style.transform = 'translateX(' + (Number(t.dataset.i) * 100) + '%)';
panels.forEach(function (p, i) {
p.style.opacity = String(i === Number(t.dataset.i) ? 1 : 0);
});
});
});
05チケット料金
中央だけに揺れるグロー
中央のカードには「人気」バッジと控えめなグローが常に揺らめきます。

<main class="stage">
<section class="tp is-demo">
<button class="tp__switch" type="button" role="switch" aria-checked="false">
<span class="tp__switch-label">早割</span>
<span class="tp__switch-track" aria-hidden="true"><span class="tp__switch-knob"></span></span>
<span class="tp__switch-label">通常</span>
</button>
<ul class="tp__cards">
<li class="tp__card">
<p class="tp__tier">スタンダード</p>
<p class="tp__price"><span class="tp__price-a">49,000ウォン</span><span class="tp__price-b">69,000ウォン</span></p>
<p class="tp__desc">全セッション・資料集</p>
</li>
<li class="tp__card tp__card--pop">
<span class="tp__badge">人気</span>
<p class="tp__tier">プレミアム</p>
<p class="tp__price"><span class="tp__price-a">89,000ウォン</span><span class="tp__price-b">119,000ウォン</span></p>
<p class="tp__desc">全セッション・ランチ</p>
</li>
<li class="tp__card">
<p class="tp__tier">VIP</p>
<p class="tp__price"><span class="tp__price-a">149,000ウォン</span><span class="tp__price-b">189,000ウォン</span></p>
<p class="tp__desc">全特典・ディナー</p>
</li>
</ul>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-base; // 스위치·가격 전환
$easing: $ease-spring;
$color: $subject-blue; // 스위치·인기 카드 강조색
@include stage($stage-ink);
.tp { width: min(420px, 94vw); }
.tp__switch {
display: flex; align-items: center; gap: $sp-2;
margin: 0 auto $sp-4;
background: none; border: 0; padding: 0;
cursor: pointer; font: inherit;
-webkit-tap-highlight-color: transparent;
}
.tp__switch-label { font-weight: 600; font-size: 11px; line-height: 1; font-family: inherit; color: rgba(255, 247, 230, .55); }
.tp__switch-track {
position: relative;
width: 34px; height: $sp-4;
border-radius: $r-pill;
background: rgba(255, 247, 230, .18);
}
.tp__switch-knob {
position: absolute; top: 2px; left: 2px;
width: 12px; height: 12px;
border-radius: 50%;
background: $subject-cream;
transform: translateX(0);
transition: transform $duration $easing;
}
.tp__switch[aria-checked="true"] .tp__switch-knob { transform: translateX(18px); background: $color; }
.tp__cards { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(3, 1fr); gap: $sp-2; }
@media (max-width: 340px) { .tp__cards { grid-template-columns: 1fr; } }
@media (max-width: 340px), (max-height: 220px) {
.tp__switch { margin-bottom: $sp-2; }
.tp__cards { grid-template-columns: repeat(3, 1fr); gap: $sp-1; }
.tp__card { padding: $sp-2 $sp-1; }
.tp__desc { display: none; }
.tp__tier { margin-bottom: $sp-1; font-size: 10px; }
}
.tp__card {
position: relative;
padding: $sp-4 $sp-2;
border-radius: $r-card;
background: rgba(255, 247, 230, .06);
border: 1px solid rgba(255, 247, 230, .12);
text-align: center;
}
.tp__card--pop {
background: rgba(47, 109, 246, .14);
border-color: $color;
animation: tp-glow 2.4s ease-in-out infinite;
}
@keyframes tp-glow {
0%, 100% { box-shadow: 0 0 0 0 rgba(47, 109, 246, .35); }
50% { box-shadow: 0 0 18px 2px rgba(47, 109, 246, .35); }
}
.tp__badge {
position: absolute; top: -10px; left: 50%; transform: translateX(-50%);
padding: $sp-1 $sp-2;
border-radius: $r-pill;
background: $subject-blue-deep;
color: $subject-cream;
font-weight: 700; font-size: 9px; line-height: 1.6; font-family: inherit;
}
.tp__tier { margin: 0 0 $sp-2; font-weight: 700; font-size: clamp(11px, 3vw, 12px); line-height: 1; font-family: inherit; color: $subject-cream; }
.tp__price { position: relative; margin: 0 0 $sp-1; min-height: 18px; }
.tp__price-a, .tp__price-b {
position: absolute; inset: 0;
font-weight: 800; font-size: clamp(12px, 3.4vw, 14px); line-height: 1; font-family: inherit;
color: $subject-cream;
transition: opacity $duration $easing;
}
.tp__price-b { opacity: 0; }
.tp__switch[aria-checked="true"] ~ .tp__cards .tp__price-a { opacity: 0; }
.tp__switch[aria-checked="true"] ~ .tp__cards .tp__price-b { opacity: 1; }
.tp__desc { margin: $sp-4 0 0; font-weight: 500; font-size: 9px; line-height: 1.4; font-family: inherit; color: rgba(255, 247, 230, .55); }
// ── 데모 루프: 클릭 전까지 얼리버드 ↔ 정가를 스스로 오간다 ──────────────
.tp.is-demo .tp__switch-knob { animation: tp-knob $dur-loop $easing infinite; }
.tp.is-demo .tp__price-a { animation: tp-a $dur-loop $easing infinite; }
.tp.is-demo .tp__price-b { animation: tp-b $dur-loop $easing infinite; }
@keyframes tp-knob {
0%, 40% { transform: translateX(0); background: $subject-cream; }
55%, 95% { transform: translateX(18px); background: $color; }
100% { transform: translateX(0); background: $subject-cream; }
}
@keyframes tp-a { 0%, 40% { opacity: 1; } 55%, 95% { opacity: 0; } 100% { opacity: 1; } }
@keyframes tp-b { 0%, 40% { opacity: 0; } 55%, 95% { opacity: 1; } 100% { opacity: 0; } }
// ── 접근성 ──────────────────────────────────────────
@media (prefers-reduced-motion: reduce) {
.tp__switch-knob, .tp__price-a, .tp__price-b { transition: none; }
.tp__card--pop { animation: none; }
.tp.is-demo .tp__switch-knob,
.tp.is-demo .tp__price-a,
.tp.is-demo .tp__price-b { 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);
}
.tp {
width: min(420px, 94vw);
}
.tp__switch {
display: flex;
align-items: center;
gap: 8px;
margin: 0 auto 16px;
background: none;
border: 0;
padding: 0;
cursor: pointer;
font: inherit;
-webkit-tap-highlight-color: transparent;
}
.tp__switch-label {
font-weight: 600;
font-size: 11px;
line-height: 1;
font-family: inherit;
color: rgba(255, 247, 230, 0.55);
}
.tp__switch-track {
position: relative;
width: 34px;
height: 16px;
border-radius: 999px;
background: rgba(255, 247, 230, 0.18);
}
.tp__switch-knob {
position: absolute;
top: 2px;
left: 2px;
width: 12px;
height: 12px;
border-radius: 50%;
background: #fff7e6;
transform: translateX(0);
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.tp__switch[aria-checked=true] .tp__switch-knob {
transform: translateX(18px);
background: #2f6df6;
}
.tp__cards {
list-style: none;
margin: 0;
padding: 0;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
}
@media (max-width: 340px) {
.tp__cards {
grid-template-columns: 1fr;
}
}
@media (max-width: 340px), (max-height: 220px) {
.tp__switch {
margin-bottom: 8px;
}
.tp__cards {
grid-template-columns: repeat(3, 1fr);
gap: 4px;
}
.tp__card {
padding: 8px 4px;
}
.tp__desc {
display: none;
}
.tp__tier {
margin-bottom: 4px;
font-size: 10px;
}
}
.tp__card {
position: relative;
padding: 16px 8px;
border-radius: 16px;
background: rgba(255, 247, 230, 0.06);
border: 1px solid rgba(255, 247, 230, 0.12);
text-align: center;
}
.tp__card--pop {
background: rgba(47, 109, 246, 0.14);
border-color: #2f6df6;
animation: tp-glow 2.4s ease-in-out infinite;
}
@keyframes tp-glow {
0%, 100% {
box-shadow: 0 0 0 0 rgba(47, 109, 246, 0.35);
}
50% {
box-shadow: 0 0 18px 2px rgba(47, 109, 246, 0.35);
}
}
.tp__badge {
position: absolute;
top: -10px;
left: 50%;
transform: translateX(-50%);
padding: 4px 8px;
border-radius: 999px;
background: #0a4fe8;
color: #fff7e6;
font-weight: 700;
font-size: 9px;
line-height: 1.6;
font-family: inherit;
}
.tp__tier {
margin: 0 0 8px;
font-weight: 700;
font-size: clamp(11px, 3vw, 12px);
line-height: 1;
font-family: inherit;
color: #fff7e6;
}
.tp__price {
position: relative;
margin: 0 0 4px;
min-height: 18px;
}
.tp__price-a, .tp__price-b {
position: absolute;
inset: 0;
font-weight: 800;
font-size: clamp(12px, 3.4vw, 14px);
line-height: 1;
font-family: inherit;
color: #fff7e6;
transition: opacity 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.tp__price-b {
opacity: 0;
}
.tp__switch[aria-checked=true] ~ .tp__cards .tp__price-a {
opacity: 0;
}
.tp__switch[aria-checked=true] ~ .tp__cards .tp__price-b {
opacity: 1;
}
.tp__desc {
margin: 16px 0 0;
font-weight: 500;
font-size: 9px;
line-height: 1.4;
font-family: inherit;
color: rgba(255, 247, 230, 0.55);
}
.tp.is-demo .tp__switch-knob {
animation: tp-knob 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.tp.is-demo .tp__price-a {
animation: tp-a 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.tp.is-demo .tp__price-b {
animation: tp-b 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes tp-knob {
0%, 40% {
transform: translateX(0);
background: #fff7e6;
}
55%, 95% {
transform: translateX(18px);
background: #2f6df6;
}
100% {
transform: translateX(0);
background: #fff7e6;
}
}
@keyframes tp-a {
0%, 40% {
opacity: 1;
}
55%, 95% {
opacity: 0;
}
100% {
opacity: 1;
}
}
@keyframes tp-b {
0%, 40% {
opacity: 0;
}
55%, 95% {
opacity: 1;
}
100% {
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.tp__switch-knob, .tp__price-a, .tp__price-b {
transition: none;
}
.tp__card--pop {
animation: none;
}
.tp.is-demo .tp__switch-knob,
.tp.is-demo .tp__price-a,
.tp.is-demo .tp__price-b {
animation: none !important;
}
}// 실제 동작: 스위치를 누르면 세 카드의 가격이 실제로 早割/정가로 바뀐다.
var sw = document.querySelector('.tp__switch');
sw.addEventListener('click', function () {
document.querySelector('.tp').classList.remove('is-demo');
var on = sw.getAttribute('aria-checked') === 'true';
sw.setAttribute('aria-checked', String(!on));
});import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./TicketPricing.module.scss";
const TIERS = [
{ name: "스탠다드", early: "49,000원", regular: "69,000원", desc: "세션 전체 · 자료집", pop: false },
{ name: "프리미엄", early: "89,000원", regular: "119,000원", desc: "세션 전체 · 네트워킹 런치", pop: true },
{ name: "VIP", early: "149,000원", regular: "189,000원", desc: "전 혜택 · 연사 디너", pop: false },
];
export type TicketPricingProps = { duration?: number; easing?: string; color?: string };
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function TicketPricing({ duration = 300, easing = "cubic-bezier(.2,.8,.2,1)", color = "#2f6df6" }: TicketPricingProps) {
const [regular, setRegular] = useState(false);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<section className={styles.tp} style={vars}>
<button
type="button"
role="switch"
aria-checked={regular}
className={styles.switch}
onClick={() => setRegular((v) => !v)}
>
<span className={styles.switchLabel}>얼리버드</span>
<span className={styles.track} aria-hidden="true">
<span className={styles.knob} style={{ transform: regular ? "translateX(18px)" : "translateX(0)" }} />
</span>
<span className={styles.switchLabel}>정가</span>
</button>
<ul className={styles.cards}>
{TIERS.map((t) => (
<li key={t.name} className={t.pop ? `${styles.card} ${styles.pop}` : styles.card}>
{t.pop && <span className={styles.badge}>인기</span>}
<p className={styles.tier}>{t.name}</p>
<p className={styles.price}>{regular ? t.regular : t.early}</p>
<p className={styles.desc}>{t.desc}</p>
</li>
))}
</ul>
</section>
);
}05 · チケット料金 — 計測 累積面積 4.375% · 動いたフレーム 8/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
スイッチで一斉に切り替わる
上部のアーリーバード/正規料金スイッチを押すと、3枚のカードの価格がクロスフェードで一斉に切り替わります。
.tp__price-a, .tp__price-b {
position: absolute; inset: 0;
transition: opacity $duration $easing;
}
.tp__price-b { opacity: 0; }
.tp__switch[aria-checked="true"] ~ .tp__cards .tp__price-a { opacity: 0; }
.tp__switch[aria-checked="true"] ~ .tp__cards .tp__price-b { opacity: 1; }
.tp__card--pop { animation: tp-glow 2.4s ease-in-out infinite; }
06スポンサーロゴバー
途切れなく流れるトラック
ロゴ8個(すべて図形のプレースホルダーで実在のロゴではありません)をつなげたトラックが途切れなく流れ続けます。

<main class="stage">
<section class="sb" aria-label="スポンサーロゴ">
<p class="sb__label">SPONSORED BY</p>
<div class="sb__viewport">
<div class="sb__track">
<span class="sb__logo sb__logo--1" aria-hidden="true"></span>
<span class="sb__logo sb__logo--2" aria-hidden="true"></span>
<span class="sb__logo sb__logo--3" aria-hidden="true"></span>
<span class="sb__logo sb__logo--4" aria-hidden="true"></span>
<span class="sb__logo sb__logo--1" aria-hidden="true"></span>
<span class="sb__logo sb__logo--2" aria-hidden="true"></span>
<span class="sb__logo sb__logo--3" aria-hidden="true"></span>
<span class="sb__logo sb__logo--4" aria-hidden="true"></span>
</div>
</div>
<button class="sb__pause" type="button" aria-pressed="false">流れを止める</button>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: 14s; // 트랙 한 바퀴 시간 (근접값 아님 — 마퀴 전용 값)
$easing: linear;
$color: $subject-ink; // 로고 마크 색
@include stage($stage-yellow);
.sb { width: min(440px, 96vw); text-align: center; }
.sb__label {
margin: 0 0 $sp-3;
font-weight: 700; font-size: 10px; line-height: 1; font-family: inherit;
letter-spacing: .16em;
color: rgba($subject-ink, .72);
}
.sb__viewport {
overflow: hidden;
-webkit-mask-image: linear-gradient(90deg, transparent, #000 8%, #000 92%, transparent);
mask-image: linear-gradient(90deg, transparent, #000 8%, #000 92%, transparent);
}
// 트랙: gap 을 쓰지 않는다(BD15) — 항목마다 margin-right 로 간격을 준다
.sb__track {
display: flex;
width: max-content;
animation: sb-scroll $duration $easing infinite;
}
.sb.is-paused .sb__track,
.sb__viewport:hover .sb__track { animation-play-state: paused; }
@keyframes sb-scroll {
from { transform: translateX(0); }
to { transform: translateX(-50%); }
}
.sb__logo {
flex: 0 0 auto;
width: 64px; height: 32px;
margin-right: $sp-6;
border-radius: $r-sm;
background: rgba(23, 20, 26, .06);
display: flex; align-items: center; justify-content: center;
gap: $sp-1;
}
.sb__logo::before {
content: "";
width: 12px; height: 12px;
background: $color;
}
.sb__logo::after {
content: "";
width: 20px; height: 6px;
border-radius: $r-xs;
background: rgba(23, 20, 26, .28);
}
.sb__logo--1::before { border-radius: 50%; }
.sb__logo--2::before { border-radius: $r-xs; transform: rotate(45deg); }
.sb__logo--3::before { clip-path: polygon(50% 0%, 0% 100%, 100% 100%); border-radius: 0; }
.sb__logo--4::before { border-radius: $r-pill; width: 16px; }
.sb__pause {
margin-top: $sp-3;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
border: 1px solid rgba(23, 20, 26, .18);
background: none;
color: rgba($subject-ink, .72);
font-weight: 600; font-size: 10px; line-height: 1.6; font-family: inherit;
cursor: pointer;
}
.sb__pause[aria-pressed="true"] { background: rgba(23, 20, 26, .08); color: $subject-ink; }
// ── 접근성 ──────────────────────────────────────────
@media (prefers-reduced-motion: reduce) {
.sb__track { animation: none !important; transform: translateX(0); }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.sb {
width: min(440px, 96vw);
text-align: center;
}
.sb__label {
margin: 0 0 12px;
font-weight: 700;
font-size: 10px;
line-height: 1;
font-family: inherit;
letter-spacing: 0.16em;
color: rgba(23, 20, 26, 0.72);
}
.sb__viewport {
overflow: hidden;
-webkit-mask-image: linear-gradient(90deg, transparent, #000 8%, #000 92%, transparent);
mask-image: linear-gradient(90deg, transparent, #000 8%, #000 92%, transparent);
}
.sb__track {
display: flex;
width: max-content;
animation: sb-scroll 14s linear infinite;
}
.sb.is-paused .sb__track,
.sb__viewport:hover .sb__track {
animation-play-state: paused;
}
@keyframes sb-scroll {
from {
transform: translateX(0);
}
to {
transform: translateX(-50%);
}
}
.sb__logo {
flex: 0 0 auto;
width: 64px;
height: 32px;
margin-right: 24px;
border-radius: 8px;
background: rgba(23, 20, 26, 0.06);
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
}
.sb__logo::before {
content: "";
width: 12px;
height: 12px;
background: #17141a;
}
.sb__logo::after {
content: "";
width: 20px;
height: 6px;
border-radius: 4px;
background: rgba(23, 20, 26, 0.28);
}
.sb__logo--1::before {
border-radius: 50%;
}
.sb__logo--2::before {
border-radius: 4px;
transform: rotate(45deg);
}
.sb__logo--3::before {
clip-path: polygon(50% 0%, 0% 100%, 100% 100%);
border-radius: 0;
}
.sb__logo--4::before {
border-radius: 999px;
width: 16px;
}
.sb__pause {
margin-top: 12px;
padding: 4px 12px;
border-radius: 999px;
border: 1px solid rgba(23, 20, 26, 0.18);
background: none;
color: rgba(23, 20, 26, 0.72);
font-weight: 600;
font-size: 10px;
line-height: 1.6;
font-family: inherit;
cursor: pointer;
}
.sb__pause[aria-pressed=true] {
background: rgba(23, 20, 26, 0.08);
color: #17141a;
}
@media (prefers-reduced-motion: reduce) {
.sb__track {
animation: none !important;
transform: translateX(0);
}
}// 실제 동작: 버튼을 누르거나 마우스를 올리면 흐름이 실제로 멈춘다(터치 기기는 버튼으로).
var root = document.querySelector('.sb');
var btn = document.querySelector('.sb__pause');
btn.addEventListener('click', function () {
var on = btn.getAttribute('aria-pressed') === 'true';
btn.setAttribute('aria-pressed', String(!on));
root.classList.toggle('is-paused', !on);
});import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./SponsorLogoBar.module.scss";
const SHAPES = ["circle", "diamond", "triangle", "pill"] as const;
export type SponsorLogoBarProps = { duration?: number; easing?: string; color?: string };
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function SponsorLogoBar({ duration = 14000, easing = "linear", color = "#17141a" }: SponsorLogoBarProps) {
const [paused, setPaused] = useState(false);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const logos = [...SHAPES, ...SHAPES];
return (
<section className={styles.sb} style={vars} aria-label="스폰서 로고">
<p className={styles.label}>SPONSORED BY</p>
<div className={styles.viewport}>
<div className={paused ? `${styles.track} ${styles.paused}` : styles.track}>
{logos.map((shape, i) => (
<span key={i} className={`${styles.logo} ${styles[shape]}`} aria-hidden="true" />
))}
</div>
</div>
<button
type="button"
className={styles.pause}
aria-pressed={paused}
onClick={() => setPaused((v) => !v)}
>
흐름 멈추기
</button>
</section>
);
}06 · スポンサーロゴ — 計測 累積面積 8.057% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
その場で止める
マウスを乗せるか一時停止ボタンをタップすると、その場で流れが止まります。
// トラック自体には gap を使わない — 項目ごとに margin-right で間隔を取る
.sb__track { display: flex; width: max-content; animation: sb-scroll 14s linear infinite; }
.sb__logo { flex: 0 0 auto; margin-right: $sp-6; }
@keyframes sb-scroll {
from { transform: translateX(0); }
to { transform: translateX(-50%); }
}
07イベントFAQ
開くのは一つだけ
質問3つのうち1つだけが開いています。

<main class="stage">
<section class="fq is-demo">
<div class="fq__item is-open" data-i="0">
<button class="fq__q" type="button" aria-expanded="true">
<span>チケットは払い戻しできますか?</span>
<span class="fq__chevron" aria-hidden="true"></span>
</button>
<div class="fq__a-wrap"><p class="fq__a">開催7日前まで全額返金、以降は手数料10%がかかります。</p></div>
</div>
<div class="fq__item" data-i="1">
<button class="fq__q" type="button" aria-expanded="false">
<span>オンラインでも参加できますか?</span>
<span class="fq__chevron" aria-hidden="true"></span>
</button>
<div class="fq__a-wrap"><p class="fq__a">現地参加のみです。主要セッションは後日動画で公開します。</p></div>
</div>
<div class="fq__item" data-i="2">
<button class="fq__q" type="button" aria-expanded="false">
<span>駐車場はありますか?</span>
<span class="fq__chevron" aria-hidden="true"></span>
</button>
<div class="fq__a-wrap"><p class="fq__a">建物の地下駐車場2時間無料、以降1時間2,000ウォンです。</p></div>
</div>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-base; // 답변 칸이 펼쳐지는 시간
$easing: $ease-spring;
$color: $subject-ink; // 화살표·강조 색
@include stage($stage-orange);
.fq { width: min(400px, 94vw); display: flex; flex-direction: column; gap: $sp-2; }
.fq__item { background: $subject-cream; border-radius: $r-card; overflow: hidden; }
.fq__q {
width: 100%;
display: flex; justify-content: space-between; align-items: center; gap: $sp-2;
padding: $sp-3 $sp-4;
background: none; border: 0; cursor: pointer; text-align: left;
font-weight: 600; font-size: clamp(11px, 3.2vw, 12px); line-height: 1.3; font-family: inherit;
color: $subject-ink;
-webkit-tap-highlight-color: transparent;
}
.fq__chevron {
flex: 0 0 auto;
width: 7px; height: 7px;
border-right: 2px solid rgba(23, 20, 26, .5);
border-bottom: 2px solid rgba(23, 20, 26, .5);
transform: rotate(-45deg);
transition: transform $duration $easing;
}
.fq__item.is-open .fq__chevron { transform: rotate(45deg); }
.fq__a-wrap {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows $duration $easing;
}
.fq__item.is-open .fq__a-wrap { grid-template-rows: 1fr; }
.fq__a {
min-height: 0;
overflow: hidden;
margin: 0;
padding: 0 $sp-4 $sp-3;
font-weight: 500; font-size: 11px; line-height: 1.5; font-family: inherit;
color: rgba(23, 20, 26, .7);
}
// ── 데모 루프: 클릭 전까지 세 질문이 순서대로 스스로 열린다 ──────────────
.fq.is-demo .fq__item:nth-child(1) .fq__a-wrap { animation: fq-open-0 $dur-loop $easing infinite; }
.fq.is-demo .fq__item:nth-child(1) .fq__chevron { animation: fq-chev-0 $dur-loop $easing infinite; }
.fq.is-demo .fq__item:nth-child(2) .fq__a-wrap { animation: fq-open-1 $dur-loop $easing infinite; }
.fq.is-demo .fq__item:nth-child(2) .fq__chevron { animation: fq-chev-1 $dur-loop $easing infinite; }
.fq.is-demo .fq__item:nth-child(3) .fq__a-wrap { animation: fq-open-2 $dur-loop $easing infinite; }
.fq.is-demo .fq__item:nth-child(3) .fq__chevron { animation: fq-chev-2 $dur-loop $easing infinite; }
@keyframes fq-open-0 { 0%, 15% { grid-template-rows: 1fr; } 30%, 100% { grid-template-rows: 0fr; } }
@keyframes fq-open-1 { 0%, 30% { grid-template-rows: 0fr; } 45%, 63% { grid-template-rows: 1fr; } 78%, 100% { grid-template-rows: 0fr; } }
@keyframes fq-open-2 { 0%, 63% { grid-template-rows: 0fr; } 78%, 96% { grid-template-rows: 1fr; } 100% { grid-template-rows: 0fr; } }
@keyframes fq-chev-0 { 0%, 15% { transform: rotate(45deg); } 30%, 100% { transform: rotate(-45deg); } }
@keyframes fq-chev-1 { 0%, 30% { transform: rotate(-45deg); } 45%, 63% { transform: rotate(45deg); } 78%, 100% { transform: rotate(-45deg); } }
@keyframes fq-chev-2 { 0%, 63% { transform: rotate(-45deg); } 78%, 96% { transform: rotate(45deg); } 100% { transform: rotate(-45deg); } }
// ── 반응형: 세로가 짧은 화면에선 여백을 줄인다(BD9) ──────────────
@media (max-width: 340px), (max-height: 220px) {
.fq { gap: $sp-1; }
.fq__q { padding: $sp-2 $sp-3; font-size: 11px; }
.fq__a { padding: 0 $sp-3 $sp-2; font-size: 10px; }
}
// ── 접근성 ──────────────────────────────────────────
@media (prefers-reduced-motion: reduce) {
.fq__chevron, .fq__a-wrap { transition: none; }
.fq.is-demo .fq__item .fq__a-wrap,
.fq.is-demo .fq__item .fq__chevron { 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);
}
.fq {
width: min(400px, 94vw);
display: flex;
flex-direction: column;
gap: 8px;
}
.fq__item {
background: #fff7e6;
border-radius: 16px;
overflow: hidden;
}
.fq__q {
width: 100%;
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
padding: 12px 16px;
background: none;
border: 0;
cursor: pointer;
text-align: left;
font-weight: 600;
font-size: clamp(11px, 3.2vw, 12px);
line-height: 1.3;
font-family: inherit;
color: #17141a;
-webkit-tap-highlight-color: transparent;
}
.fq__chevron {
flex: 0 0 auto;
width: 7px;
height: 7px;
border-right: 2px solid rgba(23, 20, 26, 0.5);
border-bottom: 2px solid rgba(23, 20, 26, 0.5);
transform: rotate(-45deg);
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.fq__item.is-open .fq__chevron {
transform: rotate(45deg);
}
.fq__a-wrap {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.fq__item.is-open .fq__a-wrap {
grid-template-rows: 1fr;
}
.fq__a {
min-height: 0;
overflow: hidden;
margin: 0;
padding: 0 16px 12px;
font-weight: 500;
font-size: 11px;
line-height: 1.5;
font-family: inherit;
color: rgba(23, 20, 26, 0.7);
}
.fq.is-demo .fq__item:nth-child(1) .fq__a-wrap {
animation: fq-open-0 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.fq.is-demo .fq__item:nth-child(1) .fq__chevron {
animation: fq-chev-0 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.fq.is-demo .fq__item:nth-child(2) .fq__a-wrap {
animation: fq-open-1 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.fq.is-demo .fq__item:nth-child(2) .fq__chevron {
animation: fq-chev-1 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.fq.is-demo .fq__item:nth-child(3) .fq__a-wrap {
animation: fq-open-2 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.fq.is-demo .fq__item:nth-child(3) .fq__chevron {
animation: fq-chev-2 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes fq-open-0 {
0%, 15% {
grid-template-rows: 1fr;
}
30%, 100% {
grid-template-rows: 0fr;
}
}
@keyframes fq-open-1 {
0%, 30% {
grid-template-rows: 0fr;
}
45%, 63% {
grid-template-rows: 1fr;
}
78%, 100% {
grid-template-rows: 0fr;
}
}
@keyframes fq-open-2 {
0%, 63% {
grid-template-rows: 0fr;
}
78%, 96% {
grid-template-rows: 1fr;
}
100% {
grid-template-rows: 0fr;
}
}
@keyframes fq-chev-0 {
0%, 15% {
transform: rotate(45deg);
}
30%, 100% {
transform: rotate(-45deg);
}
}
@keyframes fq-chev-1 {
0%, 30% {
transform: rotate(-45deg);
}
45%, 63% {
transform: rotate(45deg);
}
78%, 100% {
transform: rotate(-45deg);
}
}
@keyframes fq-chev-2 {
0%, 63% {
transform: rotate(-45deg);
}
78%, 96% {
transform: rotate(45deg);
}
100% {
transform: rotate(-45deg);
}
}
@media (max-width: 340px), (max-height: 220px) {
.fq {
gap: 4px;
}
.fq__q {
padding: 8px 12px;
font-size: 11px;
}
.fq__a {
padding: 0 12px 8px;
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.fq__chevron, .fq__a-wrap {
transition: none;
}
.fq.is-demo .fq__item .fq__a-wrap,
.fq.is-demo .fq__item .fq__chevron {
animation: none !important;
}
}// 실제 동작: 질문을 클릭하면 그 답변만 실제로 펼쳐지고 나머지는 닫힌다(단일 오픈).
var root = document.querySelector('.fq');
var items = root.querySelectorAll('.fq__item');
items.forEach(function (item) {
item.querySelector('.fq__q').addEventListener('click', function () {
root.classList.remove('is-demo');
var open = item.classList.contains('is-open');
items.forEach(function (o) {
o.classList.remove('is-open');
o.querySelector('.fq__q').setAttribute('aria-expanded', 'false');
});
if (!open) {
item.classList.add('is-open');
item.querySelector('.fq__q').setAttribute('aria-expanded', 'true');
}
});
});import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./EventFaq.module.scss";
const ITEMS = [
{ q: "티켓은 환불되나요?", a: "행사 7일 전까지 전액 환불, 이후엔 수수료 10%가 붙습니다." },
{ q: "온라인으로도 참가할 수 있나요?", a: "현장 참가만 가능합니다. 주요 세션은 이후 영상으로 공개됩니다." },
{ q: "주차는 지원되나요?", a: "건물 지하 주차장 2시간 무료, 이후 시간당 2천 원입니다." },
];
export type EventFaqProps = { duration?: number; easing?: string; color?: string };
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function EventFaq({ duration = 300, easing = "cubic-bezier(.2,.8,.2,1)", color = "#17141a" }: EventFaqProps) {
const [open, setOpen] = useState(0);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<div className={styles.fq} style={vars}>
{ITEMS.map((item, i) => (
<div key={item.q} className={i === open ? `${styles.item} ${styles.isOpen}` : styles.item}>
<button
type="button"
className={styles.q}
aria-expanded={i === open}
onClick={() => setOpen((o) => (o === i ? -1 : i))}
>
<span>{item.q}</span>
<span className={styles.chevron} aria-hidden="true" />
</button>
<div className={styles.aWrap}>
<p className={styles.a}>{item.a}</p>
</div>
</div>
))}
</div>
);
}07 · イベントFAQ — 計測 累積面積 28.795% · 動いたフレーム 10/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
どう開くのか
クリックした質問の回答欄がgrid-template-rowsで実際に開き、他は閉じ、矢印も一緒に回転します。催しではなく成果物を前に出すページなら、プロジェクト紹介のセクションテンプレートがその構成です。
.fq__a-wrap {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows $duration $easing;
}
.fq__item.is-open .fq__a-wrap { grid-template-rows: 1fr; }
.fq__a { min-height: 0; overflow: hidden; }
08申込CTA
正規表現で本当に検証する
メールアドレスを入れてボタンを押すと、実際に正規表現で形式を検証します。

<main class="stage">
<section class="rc is-demo">
<span class="rc__orb rc__orb--1" aria-hidden="true"></span>
<span class="rc__orb rc__orb--2" aria-hidden="true"></span>
<h2 class="rc__title">今すぐ登録して<br>早割特典を受け取る</h2>
<p class="rc__sub">登録者には資料集とネットワーキング招待状をお渡しします。</p>
<form class="rc__form" novalidate>
<input class="rc__input" type="email" placeholder="[email protected]" required>
<button class="rc__submit" type="submit">
<span class="rc__submit-label">登録する</span>
<svg class="rc__check" viewBox="0 0 24 24" aria-hidden="true"><polyline points="5 13 10 18 19 7" /></svg>
</button>
</form>
<p class="rc__done" role="status"></p>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-base; // 체크·버튼 전환
$easing: $ease-pop;
$color: $subject-blue; // 버튼·체크 색
@include stage($stage-paper);
.rc {
position: relative;
width: min(360px, 92vw);
text-align: center;
overflow: hidden;
isolation: isolate;
}
.rc__orb {
position: absolute; border-radius: 50%; filter: blur(20px); z-index: -1;
}
.rc__orb--1 { width: 140px; height: 140px; left: -30px; top: -20px; background: radial-gradient(circle, $subject-blue, transparent 70%); opacity: .16; animation: rc-drift 8s ease-in-out infinite; }
.rc__orb--2 { width: 120px; height: 120px; right: -20px; bottom: -30px; background: radial-gradient(circle, $subject-blue, transparent 70%); opacity: .12; animation: rc-drift 7s ease-in-out infinite reverse; }
@keyframes rc-drift {
0%, 100% { transform: translate(0, 0); }
50% { transform: translate($sp-4, -$sp-3); }
}
.rc__title { margin: 0 0 $sp-2; font-weight: 800; font-size: clamp(17px, 5vw, 21px); line-height: 1.3; font-family: inherit; color: $subject-ink; }
.rc__sub { margin: 0 0 $sp-4; font-weight: 500; font-size: clamp(11px, 3vw, 12px); line-height: 1.5; font-family: inherit; color: rgba(23, 20, 26, .6); }
.rc__form { display: flex; gap: $sp-2; justify-content: center; flex-wrap: wrap; }
.rc__input {
flex: 1 1 160px;
max-width: 220px;
padding: $sp-3 $sp-4;
border-radius: $r-control;
border: 1px solid rgba(23, 20, 26, .18);
font-weight: 500; font-size: 12px; line-height: 1; font-family: inherit;
}
.rc__input.is-shake { animation: rc-shake $dur-quick $ease-out; }
@keyframes rc-shake {
0%, 100% { transform: translateX(0); }
25% { transform: translateX(-5px); }
75% { transform: translateX(5px); }
}
.rc__submit {
display: inline-flex; align-items: center; gap: $sp-1;
padding: $sp-3 $sp-5;
border-radius: $r-control;
border: 0;
background: $subject-blue-deep;
color: $subject-cream;
font-weight: 700; font-size: 12px; line-height: 1; font-family: inherit;
cursor: pointer;
transition: transform $duration $easing;
}
.rc__check {
width: 14px; height: 14px;
fill: none;
stroke: currentColor;
stroke-width: 2.5;
stroke-linecap: round;
stroke-linejoin: round;
stroke-dasharray: 24;
stroke-dashoffset: 24;
opacity: 0;
transition: stroke-dashoffset $duration $easing, opacity $duration $easing;
}
.rc__form.is-done .rc__submit { transform: scale(1.04); }
.rc__form.is-done .rc__check { stroke-dashoffset: 0; opacity: 1; }
.rc__form.is-done .rc__submit-label::after { content: " 완료"; }
.rc__done { min-height: 14px; margin: $sp-2 0 0; font-weight: 600; font-size: 11px; line-height: 1.4; font-family: inherit; color: $color; }
// ── 데모 루프: 클릭 전까지 버튼이 맴돌고 체크가 그려졌다 지워진다 ──────────────
.rc.is-demo .rc__submit { animation: rc-pulse $dur-loop $easing infinite; }
.rc.is-demo .rc__check { animation: rc-check $dur-loop $easing infinite; }
@keyframes rc-pulse {
0%, 55%, 100% { transform: scale(1); }
70% { transform: scale(1.06); }
}
@keyframes rc-check {
0%, 55% { stroke-dashoffset: 24; opacity: 0; }
72%, 92% { stroke-dashoffset: 0; opacity: 1; }
100% { stroke-dashoffset: 24; opacity: 0; }
}
// ── 반응형: 세로가 짧은 화면에선 여백을 줄인다(BD9) ──────────────
@media (max-width: 340px), (max-height: 220px) {
.rc__title { font-size: 15px; margin-bottom: $sp-1; }
.rc__sub { margin-bottom: $sp-2; font-size: 10px; }
.rc__input { padding: $sp-2 $sp-3; }
.rc__submit { padding: $sp-2 $sp-4; }
.rc__done { min-height: 0; margin-top: $sp-1; }
}
// ── 접근성 ──────────────────────────────────────────
@media (prefers-reduced-motion: reduce) {
.rc__orb { animation: none !important; }
.rc__submit, .rc__check, .rc__input { transition: none; animation: none !important; }
.rc.is-demo .rc__submit, .rc.is-demo .rc__check { animation: none !important; }
}@charset "UTF-8";
html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.rc {
position: relative;
width: min(360px, 92vw);
text-align: center;
overflow: hidden;
isolation: isolate;
}
.rc__orb {
position: absolute;
border-radius: 50%;
filter: blur(20px);
z-index: -1;
}
.rc__orb--1 {
width: 140px;
height: 140px;
left: -30px;
top: -20px;
background: radial-gradient(circle, #2f6df6, transparent 70%);
opacity: 0.16;
animation: rc-drift 8s ease-in-out infinite;
}
.rc__orb--2 {
width: 120px;
height: 120px;
right: -20px;
bottom: -30px;
background: radial-gradient(circle, #2f6df6, transparent 70%);
opacity: 0.12;
animation: rc-drift 7s ease-in-out infinite reverse;
}
@keyframes rc-drift {
0%, 100% {
transform: translate(0, 0);
}
50% {
transform: translate(16px, -12px);
}
}
.rc__title {
margin: 0 0 8px;
font-weight: 800;
font-size: clamp(17px, 5vw, 21px);
line-height: 1.3;
font-family: inherit;
color: #17141a;
}
.rc__sub {
margin: 0 0 16px;
font-weight: 500;
font-size: clamp(11px, 3vw, 12px);
line-height: 1.5;
font-family: inherit;
color: rgba(23, 20, 26, 0.6);
}
.rc__form {
display: flex;
gap: 8px;
justify-content: center;
flex-wrap: wrap;
}
.rc__input {
flex: 1 1 160px;
max-width: 220px;
padding: 12px 16px;
border-radius: 12px;
border: 1px solid rgba(23, 20, 26, 0.18);
font-weight: 500;
font-size: 12px;
line-height: 1;
font-family: inherit;
}
.rc__input.is-shake {
animation: rc-shake 220ms ease-out;
}
@keyframes rc-shake {
0%, 100% {
transform: translateX(0);
}
25% {
transform: translateX(-5px);
}
75% {
transform: translateX(5px);
}
}
.rc__submit {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 12px 20px;
border-radius: 12px;
border: 0;
background: #0a4fe8;
color: #fff7e6;
font-weight: 700;
font-size: 12px;
line-height: 1;
font-family: inherit;
cursor: pointer;
transition: transform 300ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.rc__check {
width: 14px;
height: 14px;
fill: none;
stroke: currentColor;
stroke-width: 2.5;
stroke-linecap: round;
stroke-linejoin: round;
stroke-dasharray: 24;
stroke-dashoffset: 24;
opacity: 0;
transition: stroke-dashoffset 300ms cubic-bezier(0.2, 1.4, 0.4, 1), opacity 300ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.rc__form.is-done .rc__submit {
transform: scale(1.04);
}
.rc__form.is-done .rc__check {
stroke-dashoffset: 0;
opacity: 1;
}
.rc__form.is-done .rc__submit-label::after {
content: " 완료";
}
.rc__done {
min-height: 14px;
margin: 8px 0 0;
font-weight: 600;
font-size: 11px;
line-height: 1.4;
font-family: inherit;
color: #2f6df6;
}
.rc.is-demo .rc__submit {
animation: rc-pulse 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
.rc.is-demo .rc__check {
animation: rc-check 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
@keyframes rc-pulse {
0%, 55%, 100% {
transform: scale(1);
}
70% {
transform: scale(1.06);
}
}
@keyframes rc-check {
0%, 55% {
stroke-dashoffset: 24;
opacity: 0;
}
72%, 92% {
stroke-dashoffset: 0;
opacity: 1;
}
100% {
stroke-dashoffset: 24;
opacity: 0;
}
}
@media (max-width: 340px), (max-height: 220px) {
.rc__title {
font-size: 15px;
margin-bottom: 4px;
}
.rc__sub {
margin-bottom: 8px;
font-size: 10px;
}
.rc__input {
padding: 8px 12px;
}
.rc__submit {
padding: 8px 16px;
}
.rc__done {
min-height: 0;
margin-top: 4px;
}
}
@media (prefers-reduced-motion: reduce) {
.rc__orb {
animation: none !important;
}
.rc__submit, .rc__check, .rc__input {
transition: none;
animation: none !important;
}
.rc.is-demo .rc__submit, .rc.is-demo .rc__check {
animation: none !important;
}
}// 실제 동작: 이메일 형식을 검사해 성공/오류를 실제로 표시한다.
var form = document.querySelector('.rc__form');
var input = document.querySelector('.rc__input');
var done = document.querySelector('.rc__done');
form.addEventListener('submit', function (e) {
e.preventDefault();
document.querySelector('.rc').classList.remove('is-demo');
var ok = /^\S+@\S+\.\S+$/.test(input.value);
if (ok) {
form.classList.add('is-done');
done.textContent = '登録完了 — 確認メールをお送りしました。';
} else {
input.classList.remove('is-shake');
void input.offsetWidth;
input.classList.add('is-shake');
done.textContent = '正しいメールアドレスを入力してください。';
}
});import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./RegisterCta.module.scss";
export type RegisterCtaProps = { duration?: number; easing?: string; color?: string };
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function RegisterCta({ duration = 300, easing = "cubic-bezier(.2,1.4,.4,1)", color = "#2f6df6" }: RegisterCtaProps) {
const [email, setEmail] = useState("");
const [status, setStatus] = useState<"idle" | "error" | "done">("idle");
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
function onSubmit(e: any) {
e.preventDefault();
const ok = /^\S+@\S+\.\S+$/.test(email);
setStatus(ok ? "done" : "error");
}
return (
<section className={styles.rc} style={vars}>
<h2 className={styles.title}>지금 등록하고<br />얼리버드 혜택 받기</h2>
<p className={styles.sub}>등록자에게 자료집과 네트워킹 초대장을 드립니다.</p>
<form className={styles.form} onSubmit={onSubmit}>
<input
className={status === "error" ? `${styles.input} ${styles.shake}` : styles.input}
type="email"
placeholder="[email protected]"
value={email}
onChange={(e: any) => setEmail(e.target.value)}
/>
<button type="submit" className={styles.submit}>
{status === "done" ? "등록 완료" : "등록하기"}
<svg className={styles.check} viewBox="0 0 24 24" aria-hidden="true" style={{ opacity: status === "done" ? 1 : 0 }}>
<polyline points="5 13 10 18 19 7" />
</svg>
</button>
</form>
<p className={styles.done} role="status">
{status === "done" ? "등록 완료 — 확인 메일을 보냈습니다." : status === "error" ? "올바른 이메일을 입력해 주세요." : ""}
</p>
</section>
);
}08 · 申込CTA — 計測 累積面積 0.857% · 動いたフレーム 9/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
通過と失敗の合図
通過するとボタンにチェックが描かれて「登録完了」に変わり、形式が誤っていると入力欄が左右に短く揺れます。
form.addEventListener('submit', function (e) {
e.preventDefault();
var ok = /^\S+@\S+\.\S+$/.test(input.value);
if (ok) {
form.classList.add('is-done');
done.textContent = '登録完了 — 確認メールを送信しました。';
} else {
input.classList.remove('is-shake');
void input.offsetWidth;
input.classList.add('is-shake');
done.textContent = '正しいメールアドレスを入力してください。';
}
});
09カウントダウン
目標時刻まで秒単位で
ページを開いた瞬間からちょうど7日4時間20分15秒後を目標時刻とし、1秒ごとに実際に数字が減っていきます。

<main class="stage">
<section class="cd">
<span class="cd__glow" aria-hidden="true"></span>
<p class="cd__label">カンファレンス開始まで</p>
<div class="cd__row" role="timer" aria-live="off">
<div class="cd__unit"><span class="cd__num" data-u="d">07</span><span class="cd__unit-label">日</span></div>
<span class="cd__sep">:</span>
<div class="cd__unit"><span class="cd__num" data-u="h">04</span><span class="cd__unit-label">時</span></div>
<span class="cd__sep">:</span>
<div class="cd__unit"><span class="cd__num" data-u="m">20</span><span class="cd__unit-label">分</span></div>
<span class="cd__sep">:</span>
<div class="cd__unit"><span class="cd__num cd__num--s" data-u="s">15</span><span class="cd__unit-label">秒</span></div>
</div>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-quick; // 초 카드 틱 전환
$easing: $ease-pop;
$color: $subject-blue; // 숫자 카드 강조색
@include stage($stage-ink);
.cd {
position: relative;
box-sizing: border-box; // width 에 패딩을 포함 — 아니면 320px 에서 패딩만큼 넘친다(실측 2026-09-10)
width: min(380px, 94vw);
text-align: center;
padding: $sp-6 $sp-4;
isolation: isolate;
overflow: hidden;
}
.cd__glow {
position: absolute; inset: -20% -10%;
z-index: -1;
background: radial-gradient(circle, rgba(47, 109, 246, .3), transparent 70%);
animation: cd-glow 2.6s ease-in-out infinite;
}
@keyframes cd-glow {
0%, 100% { opacity: .5; transform: scale(1); }
50% { opacity: .9; transform: scale(1.08); }
}
.cd__label {
margin: 0 0 $sp-4;
font-weight: 700; font-size: 11px; line-height: 1; font-family: inherit;
letter-spacing: .1em;
color: rgba(255, 247, 230, .6);
}
.cd__row { display: flex; align-items: center; justify-content: center; gap: $sp-2; }
.cd__unit { display: flex; flex-direction: column; align-items: center; gap: $sp-1; }
.cd__num {
display: block;
min-width: 40px;
padding: $sp-2 $sp-1;
border-radius: $r-sm;
background: rgba(255, 247, 230, .08);
color: $subject-cream;
font: 800 clamp(18px, 5.6vw, 24px)/1 "Space Mono", monospace;
font-variant-numeric: tabular-nums;
}
.cd__num--s { background: rgba(47, 109, 246, .22); color: $subject-cream; animation: cd-tick 1s $easing infinite; transform-origin: center; }
@keyframes cd-tick {
0%, 55% { transform: translateY(0) scale(1); }
78% { transform: translateY(-3px) scale(1.1); }
100% { transform: translateY(0) scale(1); }
}
.cd__unit-label { font-weight: 600; font-size: 9px; line-height: 1; font-family: inherit; color: rgba(255, 247, 230, .5); }
.cd__sep { font-weight: 700; font-size: 16px; line-height: 1; font-family: inherit; color: rgba($subject-cream, .72); margin-top: -$sp-4; }
// ── 접근성 ──────────────────────────────────────────
@media (prefers-reduced-motion: reduce) {
.cd__glow, .cd__num--s { 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);
}
.cd {
position: relative;
box-sizing: border-box;
width: min(380px, 94vw);
text-align: center;
padding: 24px 16px;
isolation: isolate;
overflow: hidden;
}
.cd__glow {
position: absolute;
inset: -20% -10%;
z-index: -1;
background: radial-gradient(circle, rgba(47, 109, 246, 0.3), transparent 70%);
animation: cd-glow 2.6s ease-in-out infinite;
}
@keyframes cd-glow {
0%, 100% {
opacity: 0.5;
transform: scale(1);
}
50% {
opacity: 0.9;
transform: scale(1.08);
}
}
.cd__label {
margin: 0 0 16px;
font-weight: 700;
font-size: 11px;
line-height: 1;
font-family: inherit;
letter-spacing: 0.1em;
color: rgba(255, 247, 230, 0.6);
}
.cd__row {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.cd__unit {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
}
.cd__num {
display: block;
min-width: 40px;
padding: 8px 4px;
border-radius: 8px;
background: rgba(255, 247, 230, 0.08);
color: #fff7e6;
font: 800 clamp(18px, 5.6vw, 24px)/1 "Space Mono", monospace;
font-variant-numeric: tabular-nums;
}
.cd__num--s {
background: rgba(47, 109, 246, 0.22);
color: #fff7e6;
animation: cd-tick 1s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
transform-origin: center;
}
@keyframes cd-tick {
0%, 55% {
transform: translateY(0) scale(1);
}
78% {
transform: translateY(-3px) scale(1.1);
}
100% {
transform: translateY(0) scale(1);
}
}
.cd__unit-label {
font-weight: 600;
font-size: 9px;
line-height: 1;
font-family: inherit;
color: rgba(255, 247, 230, 0.5);
}
.cd__sep {
font-weight: 700;
font-size: 16px;
line-height: 1;
font-family: inherit;
color: rgba(255, 247, 230, 0.72);
margin-top: -16px;
}
@media (prefers-reduced-motion: reduce) {
.cd__glow, .cd__num--s {
animation: none !important;
}
}// 실제 동작: 목표 시각(지금부터 정확히 7日 4시간 20分 15秒 뒤)까지 1초마다 실제로 줄어든다.
var target = Date.now() + ((7 * 24 * 60 * 60) + (4 * 60 * 60) + (20 * 60) + 15) * 1000;
var els = {
d: document.querySelector('[data-u="d"]'),
h: document.querySelector('[data-u="h"]'),
m: document.querySelector('[data-u="m"]'),
s: document.querySelector('[data-u="s"]')
};
function pad(n) { return String(Math.max(0, n)).padStart(2, '0'); }
function tick() {
var diff = Math.max(0, target - Date.now());
var s = Math.floor(diff / 1000);
els.d.textContent = pad(Math.floor(s / 86400));
els.h.textContent = pad(Math.floor((s % 86400) / 3600));
els.m.textContent = pad(Math.floor((s % 3600) / 60));
els.s.textContent = pad(s % 60);
}
tick();
setInterval(tick, 1000);import { useEffect, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./CountdownBand.module.scss";
function pad(n: number) {
return String(Math.max(0, n)).padStart(2, "0");
}
export type CountdownBandProps = { duration?: number; easing?: string; color?: string; targetMs?: number };
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function CountdownBand({
duration = 220,
easing = "cubic-bezier(.2,1.4,.4,1)",
color = "#2f6df6",
targetMs = Date.now() + ((7 * 24 * 60 * 60) + (4 * 60 * 60) + (20 * 60) + 15) * 1000,
}: CountdownBandProps) {
const [left, setLeft] = useState(Math.max(0, targetMs - Date.now()));
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
useEffect(() => {
const id = setInterval(() => setLeft(Math.max(0, targetMs - Date.now())), 1000);
return () => clearInterval(id);
}, [targetMs]);
const s = Math.floor(left / 1000);
const d = Math.floor(s / 86400);
const h = Math.floor((s % 86400) / 3600);
const m = Math.floor((s % 3600) / 60);
const sec = s % 60;
return (
<section className={styles.cd} style={vars}>
<span className={styles.glow} aria-hidden="true" />
<p className={styles.label}>컨퍼런스 시작까지</p>
<div className={styles.row} role="timer" aria-live="off">
<div className={styles.unit}><span className={styles.num}>{pad(d)}</span><span className={styles.unitLabel}>일</span></div>
<span className={styles.sep}>:</span>
<div className={styles.unit}><span className={styles.num}>{pad(h)}</span><span className={styles.unitLabel}>시</span></div>
<span className={styles.sep}>:</span>
<div className={styles.unit}><span className={styles.num}>{pad(m)}</span><span className={styles.unitLabel}>분</span></div>
<span className={styles.sep}>:</span>
<div className={styles.unit}><span className={`${styles.num} ${styles.numS}`}>{pad(sec)}</span><span className={styles.unitLabel}>초</span></div>
</div>
</section>
);
}09 · カウントダウン — 計測 累積面積 16.861% · 動いたフレーム 12/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
毎秒跳ねる秒カード
秒のカードは毎秒わずかに跳ね上がります。
var target = Date.now() + ((7 * 24 * 60 * 60) + (4 * 60 * 60) + (20 * 60) + 15) * 1000;
function tick() {
var diff = Math.max(0, target - Date.now());
var s = Math.floor(diff / 1000);
els.d.textContent = pad(Math.floor(s / 86400));
els.h.textContent = pad(Math.floor((s % 86400) / 3600));
els.m.textContent = pad(Math.floor((s % 3600) / 60));
els.s.textContent = pad(s % 60);
}
tick();
setInterval(tick, 1000);
どこで壊れるか — 落とし穴
狭い画面ではみ出す
9つを作る中で実際に踏んだ落とし穴は、画面サイズでした。480×300のキャンバスでは最初、ヒーローが縦の余白を取りすぎてキャンバス内にスクロールバーが出ました。パディングと行間を一段ずつ削ってようやく収まりました。本当に苦しんだのは幅320px・高さ200pxの狭い画面です。登壇者グリッド・会場案内・チケット料金・FAQ・申込CTAの5つが、この大きさで高さ260〜450pxまではみ出しました。2列に畳めば解決するわけではなく、登壇者グリッドはむしろ3列を保ったまま、役割説明のような二次情報だけを狭い画面で隠す形に直しました。
3Dフリップと継ぎ目
カウントダウンの秒カードは最初rotateX(0 → -90deg → 0)で丸ごと反転させていましたが、perspectiveを与えずに90度の地点を通過する瞬間、数字が完全に平らになってキャプチャ画面では線一本("--")のように写りました。3DフリップをやめてtranslateY+scaleで軽く跳ねる動きに変えたところ解決しました。日付を過ぎても残るカウントダウンのように、時間の付いた部品はダッシュボードのコンポーネントでも更新の周期を決めておかないと同じように古びます。スポンサーロゴバーのようにtranslateX(-50%)で無限スクロールするトラックは、項目間の間隔をgapではなくmargin-rightで取る必要があります。gapを使うと2周目(2N個目)の継ぎ目だけgap ÷ 2分足りなくなり、トラックが一周するたびにわずかに跳ねます。この二つを含めてレスポンシブとアニメーションを直したあと、5ngtcz6hで最新状態のzipを開くと、9つは上の説明どおりに動きます。
アクセシビリティ
動きを止めても状態は残る
9つ全部がprefers-reduced-motion: reduceで装飾的な遷移とアニメーションを止めつつ、状態そのものは残します — スケジュールの進捗バーは埋まったまま、FAQの回答は開いたまま固定され、カウントダウンの数字更新だけはそのまま続きます。
タブとARIAパターン
会場案内(04)はrole="tablist"・role="tab"・aria-selectedを実際に備えたタブで、申込CTA(08)の結果文はrole="status"(aria-live)なのでスクリーンリーダーが送信結果をすぐに読み上げます。カウントダウン(09)はrole="timer"にaria-live="off"を与えて、毎秒更新される数字がスクリーンリーダーの邪魔をしないようにしました。
status と timer の使い分け
タブとアコーディオンのキーボード操作はW3C ARIA Authoring Practicesの基準に沿っています。ほかのランディングページ 作り方の素材はテンプレートカテゴリーに、このサイトが何を配っているかは紹介ページにまとめています。
FAQ
この lp テンプレート に実際のロゴや写真を使っても大丈夫ですか?
はい、それが目的です。9つとも写真・ロゴの場所は図形のプレースホルダーなので、実際の画像やロゴに差し替えるだけで使えます。色はdemo-tokens.scssの値をそのまま使ってもよく、ブランドカラーに変えてもレイアウトはそのまま保たれます。
React版はCSS版と動きが違いますか?
動作は同じです。選んだタブや開いたアコーディオンのような状態はuseStateで持ち、変数3つ(duration・easing・color)はpropsで受け取ってCSS変数(--duration・--easing・--color)に渡します。カウントダウンはuseEffectの中で同じsetIntervalの処理をそのまま使います。
カウントダウンは本当に動いていますか、それとも飾りですか?
実際に動いています。ページを開いた時点からちょうど7日4時間20分15秒後を目標にして1秒ごとに更新されます。目標時刻はtargetの値ひとつを実際のイベント日に変えるだけでそのまま使えます。自動再生のgifで見える秒カードの跳ねる動きは別の@keyframesによる装飾で、数字の更新とは独立して動きます。









