이벤트 페이지 템플릿 9종 — 그대로 붙이는 섹션
이벤트 페이지 템플릿은 컨퍼런스·밋업 랜딩 페이지에 바로 붙이는 히어로·일정·연사·가격 같은 섹션을 통째로 담은 코드 모음입니다. 아홉 개 중 하나는 실제 목표 시각까지 초 단위로 줄어드는 카운트다운을 담았습니다.
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
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
아홉 개는 인기 순이 아니라 방문자가 랜딩 페이지를 훑는 순서대로 놓았습니다. 먼저 눈길을 끌고(히어로) 일정을 확인시키고(타임라인) 신뢰를 쌓은 뒤(연사) 실행 정보를 주고(장소) 구매를 결정시키고(가격) 신뢰를 한 번 더 보강하고(스폰서) 남은 의심을 없앤 다음(FAQ) 전환시키고(등록 CTA) 마지막으로 지금 해야 할 이유를 못 박습니다(카운트다운). 사진·로고 자리는 전부 이 사이트의 먹·파랑·크림 토큰으로 만든 플레이스홀더 도형이라, 실제 로고와 사진만 갈아 끼우면 이벤트 상세페이지 디자인이 그대로 완성됩니다. 행사가 끝난 뒤 보도자료를 모아 둘 자리가 필요하면 프레스룸 섹션 템플릿이 이어지고, 참가자 현황을 숫자로 보여 줄 판은 대시보드 위젯 UI에 있습니다.
01이벤트 히어로
히어로 섹션 디자인의 뼈대
무대 뒤 흐릿한 원 세 개가 계속 떠다니는 동안 워드마크(도형 플레이스홀더)·헤드라인·날짜/장소 알약·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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
관심 등록 버튼은 진짜다
오른쪽 "관심 등록" 버튼은 클릭마다 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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
지금 연 정류장만 강조
지금 연 정류장의 점만 펄스 링으로 강조됩니다.
.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연사 그리드
여섯 장이 순서대로 떠오른다
카드 여섯 장이 순서대로 살짝 기울었다 펴지며 떠오릅니다.

<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">숫자보다 먼저 봐야 하는 사용자 신호 세 가지.</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">세 번 실패하고 배운 것만 골라 짧게 말합니다.</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>
</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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
클릭한 카드만 펼쳐진다
카드를 클릭하면 그 카드만 한 줄짜리 소개가 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시간 무료, 이후 시간당 2천 원.</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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
상태가 바뀌는 방식
여기는 :checked 대신 진짜 버튼 클릭으로 상태가 바뀝니다.
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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
스위치로 가격이 바뀐다
위쪽 얼리버드/정가 스위치를 누르면 세 카드의 가격 숫자가 한 번에 크로스페이드로 바뀝니다.
.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스폰서 로고 바
끊김 없이 흐르는 로고
로고 여덟 개(전부 도형 플레이스홀더, 실제 로고 아님)가 트랙을 이어 붙여 끊김 없이 흐릅니다.

<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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
올리면 그 자리에서 멈춘다
마우스를 올리거나 버튼을 탭하면 그 자리에서 멈춥니다.
// 트랙 자체엔 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
한 번에 하나만 열린다
질문 세 개 중 하나만 열려 있습니다.

<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시간 무료, 이후 시간당 2천 원입니다.</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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
펼쳐지는 방식
클릭한 질문의 답변 칸이 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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
통과와 실패의 신호
통과하면 버튼에 체크가 그려지고 아래에 "등록 완료" 안내 문구가 뜨며, 형식이 틀리면 입력칸이 좌우로 짧게 흔들립니다.
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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
매초 살짝 튀는 초 카드
초 카드는 매초 살짝 튀어 오릅니다.
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);
어디서 깨지는가 — 함정
좁은 화면에서 넘친다
아홉 개를 만드는 동안 진짜 걸린 함정은 화면 크기였습니다. 480×300 캔버스에서는 멀쩡하던 히어로가 처음엔 세로 여백을 너무 많이 잡아먹어 그 안에서도 스크롤바가 떴습니다. 패딩과 줄간격을 한 단계씩 줄여서야 통과했습니다. 진짜 문제는 폭 320px·높이 200px의 좁은 화면이었습니다. 연사 그리드·장소 안내·티켓 가격표·FAQ·등록 CTA 다섯 개가 이 크기에서 키가 260~450px까지 넘쳤습니다. 2열로 접는 게 능사가 아니어서, 연사 그리드는 오히려 3열을 유지하고 역할 설명 같은 2차 정보만 좁은 화면에서 숨기는 쪽으로 고쳤습니다.
3D 플립과 트랙 이음새
카운트다운 초 카드를 처음엔 rotateX(0 → -90deg → 0)로 통째로 뒤집었는데, perspective 없이 90도 지점을 지나는 순간 숫자가 완전히 납작해져 캡처 화면에 선 하나("--")처럼 찍혔습니다. 3D 플립 대신 translateY+scale로 살짝 튀는 정도로 바꾸자 해결됐습니다. 행사 날짜가 지나면 남는 카운트다운처럼 시간이 붙은 부품은 대시보드 컴포넌트에서도 갱신 주기를 정해 두지 않으면 같은 식으로 낡습니다. 스폰서 로고 바처럼 translateX(-50%)로 무한 스크롤하는 트랙은 항목 사이 간격을 gap이 아니라 margin-right로 줘야 합니다. gap을 쓰면 두 벌(2N개) 사이 이음새가 gap ÷ 2만큼 모자라 트랙이 한 바퀴 돌 때마다 살짝 튑니다. 이 두 가지를 포함해 반응형과 애니메이션을 전부 손본 다음, 5ngtcz6h으로 최신 상태의 zip 을 열어 보면 아홉 개가 위 표대로 정확히 동작합니다.
접근성
동작 줄이기에서도 상태는 남는다
아홉 개 전부 prefers-reduced-motion: reduce에서 장식 전환·애니메이션을 끄되 상태 자체는 남깁니다 — 일정 진행바는 채워진 채로, FAQ 답변은 열린 채로 고정되고 카운트다운 숫자 갱신만은 그대로 유지합니다.
탭과 상태 알림
장소 안내(04)는 role="tablist"·role="tab"·aria-selected를 실제로 갖춘 탭이고, 등록 CTA(08)의 결과 문구는 role="status"(aria-live)라 스크린 리더가 제출 결과를 바로 읽습니다. 카운트다운(09)은 role="timer"에 aria-live="off"를 줘서 매초 갱신되는 숫자가 스크린 리더를 방해하지 않게 했습니다.
더 읽을 거리
탭·아코디언은 표준 button 요소로 만들어 Tab·Enter로 그대로 조작되고, role과 상태 속성은 W3C ARIA Authoring Practices의 탭 패턴을 참고했습니다. 다른 랜딩 페이지 섹션 소재는 템플릿 카테고리에 모여 있고, 이 사이트가 무엇을 다루는지는 소개에 있습니다.
FAQ
이벤트 랜딩페이지에 실제 로고나 사진을 써도 되나요?
네, 그게 목적입니다. 아홉 개 전부 사진·로고 자리는 도형 플레이스홀더라 실제 이미지·로고로 갈아 끼우기만 하면 됩니다. 색은 demo-tokens.scss 값을 그대로 써도 되고, 브랜드 색으로 바꿔도 레이아웃은 그대로 유지됩니다.
React 버전은 CSS 버전과 뭐가 다른가요?
동작은 같습니다. 활성 탭·열린 아코디언 같은 상태는 useState로 들고, 변수 3개(duration·easing·color)는 props로 받아 CSS 변수(--duration --easing --color)로 넘깁니다. 카운트다운은 useEffect 안에서 같은 setInterval 로직을 그대로 씁니다.
카운트다운이 실제로 작동하나요, 아니면 장식인가요?
실제로 작동합니다. 페이지를 연 시점부터 정확히 7일 4시간 20분 15초 뒤를 목표로 잡고 1초마다 갱신됩니다. 목표 시각은 targetMs 값 하나만 실제 행사 날짜로 바꾸면 그대로 쓸 수 있습니다. 자동 재생 GIF에서 보이는 초 카드의 튀는 동작은 별도 @keyframes 장식이고, 숫자 갱신과는 독립적으로 작동합니다.









