언론보도 페이지 템플릿 9종 — 프레스룸 섹션 모음
미디어·프레스룸 섹션 템플릿은 보도자료·미디어 키트·연락처 같은 언론보도 페이지 구성 요소를 통째로 담은 코드 모음입니다. 랜딩 페이지에 바로 붙이는 히어로부터 구독 푸터까지, 아홉 개 중 하나는 클립보드로 실제 이메일을 복사하는 버튼을 담았습니다.
01 press-hero
02 press-release-list
03 press-media-kit
04 press-mentions-bar
05 press-stat-counter
06 press-contact
07 press-founder-quote
08 press-milestone-timeline
09 press-footer
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
아홉 개는 인기순이 아니라 기자·투자자가 프레스룸을 훑는 순서대로 놓았습니다. 먼저 첫인상을 주고(히어로), 최신 소식을 확인시키고(보도자료 목록), 취재에 쓸 자료를 내려받게 하고(미디어 키트), 이미 실린 곳을 보여주고(언론 언급 바), 규모를 숫자로 증명하고(숫자 하이라이트), 담당자에게 바로 연락하게 하고(문의 카드), 브랜드의 목소리를 들려주고(창업자 인용), 지금까지 걸어온 길을 보여준 다음(연혁 타임라인), 다음 소식을 받아보게 합니다(구독 푸터). 담당자 얼굴·언론사 로고 자리는 실제 이미지 대신 이 사이트의 먹·파랑·크림 토큰만으로 그린 도형 플레이스홀더입니다. 이미지 파일을 교체해도 배치와 여백은 그대로 유지됩니다. 발표가 행사와 묶여 있다면 이벤트 페이지 템플릿이 그 앞자리를 채우고, 보도 대신 결과물을 늘어놓을 자리는 프로젝트 쇼케이스 템플릿입니다.
01프레스 히어로
프레스룸 디자인의 첫 화면
무대 뒤 흐릿한 원 세 개가 계속 떠다니는 동안 워드마크(도형 플레이스홀더)·헤드라인·부제·CTA 두 개가 한 화면에 자리 잡습니다.

<main class="stage">
<section class="ph">
<span class="ph__orb ph__orb--1" aria-hidden="true"></span>
<span class="ph__orb ph__orb--2" aria-hidden="true"></span>
<span class="ph__orb ph__orb--3" aria-hidden="true"></span>
<span class="ph__mark" aria-hidden="true"></span>
<p class="ph__eyebrow">PRESS ROOM</p>
<h1 class="ph__title">언론과 파트너를 위한<br>최신 소식</h1>
<p class="ph__sub">보도자료·미디어 키트·연락처를 한 곳에서 확인하세요.</p>
<div class="ph__row">
<a class="ph__cta" href="#">보도자료 보기</a>
<button class="ph__sub-btn" type="button" aria-pressed="false">
<span class="ph__sub-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);
.ph {
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;
}
// ── 배경 원 세 개: 계속 떠다니는 장식(호버 아님, 무조건 재생) ──────────────
.ph__orb {
position: absolute;
border-radius: 50%;
filter: blur(18px);
opacity: .55;
z-index: -1;
}
.ph__orb--1 { width: 170px; height: 170px; left: -45px; top: -35px; background: radial-gradient(circle, $subject-blue, transparent 62%); opacity: 1; filter: blur(3px); animation: ph-float-a 2s ease-in-out infinite; }
.ph__orb--2 { width: 130px; height: 130px; right: -25px; top: 6px; background: radial-gradient(circle, $subject-cream, transparent 62%); opacity: .7; filter: blur(3px); animation: ph-float-b 2s ease-in-out infinite; }
.ph__orb--3 { width: 150px; height: 150px; left: 28%; bottom: -70px; background: radial-gradient(circle, $subject-blue, transparent 62%); opacity: .95; filter: blur(3px); animation: ph-float-a 2s ease-in-out infinite reverse; }
@keyframes ph-float-a {
0%, 100% { transform: translate(0, 0) scale(1); }
50% { transform: translate($sp-10, -$sp-8) scale(1.5); }
}
@keyframes ph-float-b {
0%, 100% { transform: translate(0, 0) scale(1); }
50% { transform: translate(-$sp-8, $sp-8) scale(.6); }
}
// ── 플레이스홀더 워드마크: 실제 로고가 아니라 도형 하나 ──────────────
.ph__mark {
display: block;
width: $sp-5; height: $sp-5;
margin: 0 auto $sp-1;
background: $color;
border-radius: $r-xs;
transform: rotate(45deg);
}
.ph__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);
}
.ph__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;
}
.ph__sub {
margin: 0 0 $sp-2;
font-weight: 500; font-size: clamp(10px, 2.8vw, 11px); line-height: 1.4; font-family: inherit;
color: rgba(255, 247, 230, .68);
}
.ph__row { display: flex; justify-content: center; align-items: center; gap: $sp-2; flex-wrap: wrap; }
.ph__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;
}
.ph__sub-btn {
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;
}
.ph__sub-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;
}
.ph__sub-btn[aria-pressed="true"] {
background: rgba(255, 247, 230, .12);
border-color: $subject-cream;
}
.ph__sub-btn[aria-pressed="true"] .ph__sub-ic {
opacity: 1;
color: $color;
transform: scale(1.15);
}
// ── 반응형: 320×200 처럼 세로가 짧은 화면에선 장식을 줄인다(BD9) ──────────────
@media (max-width: 340px), (max-height: 220px) {
.ph { padding: $sp-2 $sp-4; }
.ph__mark { display: none; }
.ph__eyebrow { display: none; }
.ph__title { font-size: 14px; line-height: 1.15; margin-bottom: $sp-1; }
.ph__sub { display: none; }
.ph__row { gap: $sp-1; }
.ph__cta, .ph__sub-btn { padding: 4px $sp-3; font-size: 10px; }
}
// ── 접근성 ──────────────────────────────────────────
@media (prefers-reduced-motion: reduce) {
.ph__orb { animation: none !important; }
.ph__sub-btn, .ph__sub-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);
}
.ph {
position: relative;
box-sizing: border-box;
width: min(400px, 92vw);
overflow: hidden;
border-radius: 16px;
padding: 12px 20px 8px;
text-align: center;
isolation: isolate;
}
.ph__orb {
position: absolute;
border-radius: 50%;
filter: blur(18px);
opacity: 0.55;
z-index: -1;
}
.ph__orb--1 {
width: 170px;
height: 170px;
left: -45px;
top: -35px;
background: radial-gradient(circle, #2f6df6, transparent 62%);
opacity: 1;
filter: blur(3px);
animation: ph-float-a 2s ease-in-out infinite;
}
.ph__orb--2 {
width: 130px;
height: 130px;
right: -25px;
top: 6px;
background: radial-gradient(circle, #fff7e6, transparent 62%);
opacity: 0.7;
filter: blur(3px);
animation: ph-float-b 2s ease-in-out infinite;
}
.ph__orb--3 {
width: 150px;
height: 150px;
left: 28%;
bottom: -70px;
background: radial-gradient(circle, #2f6df6, transparent 62%);
opacity: 0.95;
filter: blur(3px);
animation: ph-float-a 2s ease-in-out infinite reverse;
}
@keyframes ph-float-a {
0%, 100% {
transform: translate(0, 0) scale(1);
}
50% {
transform: translate(40px, -32px) scale(1.5);
}
}
@keyframes ph-float-b {
0%, 100% {
transform: translate(0, 0) scale(1);
}
50% {
transform: translate(-32px, 32px) scale(0.6);
}
}
.ph__mark {
display: block;
width: 20px;
height: 20px;
margin: 0 auto 4px;
background: #2f6df6;
border-radius: 4px;
transform: rotate(45deg);
}
.ph__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);
}
.ph__title {
margin: 0 0 4px;
font-weight: 800;
font-size: clamp(16px, 4.6vw, 19px);
line-height: 1.14;
font-family: inherit;
color: #fff7e6;
}
.ph__sub {
margin: 0 0 8px;
font-weight: 500;
font-size: clamp(10px, 2.8vw, 11px);
line-height: 1.4;
font-family: inherit;
color: rgba(255, 247, 230, 0.68);
}
.ph__row {
display: flex;
justify-content: center;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.ph__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;
}
.ph__sub-btn {
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);
}
.ph__sub-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);
}
.ph__sub-btn[aria-pressed=true] {
background: rgba(255, 247, 230, 0.12);
border-color: #fff7e6;
}
.ph__sub-btn[aria-pressed=true] .ph__sub-ic {
opacity: 1;
color: #2f6df6;
transform: scale(1.15);
}
@media (max-width: 340px), (max-height: 220px) {
.ph {
padding: 8px 16px;
}
.ph__mark {
display: none;
}
.ph__eyebrow {
display: none;
}
.ph__title {
font-size: 14px;
line-height: 1.15;
margin-bottom: 4px;
}
.ph__sub {
display: none;
}
.ph__row {
gap: 4px;
}
.ph__cta, .ph__sub-btn {
padding: 4px 12px;
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.ph__orb {
animation: none !important;
}
.ph__sub-btn, .ph__sub-ic {
transition: none;
}
}// 실제 동작: 클릭마다 구독 상태가 실제로 바뀐다(아이콘 채움 + aria-pressed).
var btn = document.querySelector('.ph__sub-btn');
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 "./PressHero.module.scss";
export type PressHeroProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function PressHero({ duration = 220, easing = "cubic-bezier(.2,1.4,.4,1)", color = "#2f6df6" }: PressHeroProps) {
const [subscribed, setSubscribed] = useState(false);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<section className={styles.ph} style={vars}>
<span className={`${styles.orb} ${styles.orb1}`} aria-hidden="true" />
<span className={`${styles.orb} ${styles.orb2}`} aria-hidden="true" />
<span className={`${styles.orb} ${styles.orb3}`} aria-hidden="true" />
<span className={styles.mark} aria-hidden="true" />
<p className={styles.eyebrow}>PRESS ROOM</p>
<h1 className={styles.title}>언론과 파트너를 위한<br />최신 소식</h1>
<p className={styles.sub}>보도자료·미디어 키트·연락처를 한 곳에서 확인하세요.</p>
<div className={styles.row}>
<a className={styles.cta} href="#">보도자료 보기</a>
<button
type="button"
className={styles.subBtn}
aria-pressed={subscribed}
onClick={() => setSubscribed((v) => !v)}
>
<span className={styles.subIc} aria-hidden="true" />
보도자료 구독
</button>
</div>
</section>
);
}01 · 프레스 히어로 — 측정 누적면적 30.411% · 움직인 프레임 18/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
구독 버튼은 진짜 상태를 바꾼다
오른쪽 "보도자료 구독" 버튼을 누를 때마다 구독 상태와 아이콘 채움이 함께 바뀝니다.
.ph__orb--1 { animation: ph-float-a 2s ease-in-out infinite; }
@keyframes ph-float-a {
0%, 100% { transform: translate(0, 0) scale(1); }
50% { transform: translate($sp-10, -$sp-8) scale(1.5); }
}
.ph__sub-btn[aria-pressed="true"] {
background: rgba(255, 247, 230, .12);
border-color: $subject-cream;
}
.ph__sub-btn[aria-pressed="true"] .ph__sub-ic {
opacity: 1;
color: $color;
transform: scale(1.15);
}
02보도자료 목록
보도자료 양식을 분류로 거른다
분류 칩(전체·제품·파트너십·수상)을 클릭하면 그 분류가 아닌 행은 실제로 접히고 나머지만 남습니다.

<main class="stage">
<section class="rl is-demo">
<div class="rl__filters" role="group" aria-label="분류">
<button class="rl__chip" type="button" data-cat="all" aria-pressed="true">전체</button>
<button class="rl__chip" type="button" data-cat="product" aria-pressed="false">제품</button>
<button class="rl__chip" type="button" data-cat="partner" aria-pressed="false">파트너십</button>
<button class="rl__chip" type="button" data-cat="award" aria-pressed="false">수상</button>
</div>
<ul class="rl__list">
<li class="rl__row" data-cat="product"><span class="rl__date">08.20</span><span class="rl__title">새 워크스페이스 기능 출시</span><span class="rl__tag">제품</span></li>
<li class="rl__row" data-cat="partner"><span class="rl__date">07.02</span><span class="rl__title">B사와 유통 파트너십 체결</span><span class="rl__tag">파트너십</span></li>
<li class="rl__row" data-cat="award"><span class="rl__date">06.15</span><span class="rl__title">올해의 스타트업상 수상</span><span class="rl__tag">수상</span></li>
<li class="rl__row" data-cat="product"><span class="rl__date">05.30</span><span class="rl__title">3.0 버전 업데이트 공개</span><span class="rl__tag">제품</span></li>
</ul>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-base; // 칩·행 전환
$easing: $ease-spring;
$color: $subject-ink; // 강조 악센트 색
@include stage($stage-yellow);
.rl {
box-sizing: border-box;
width: min(400px, 94vw);
background: $subject-cream;
border-radius: $r-card;
padding: $sp-2 $sp-4;
}
.rl__filters { display: flex; gap: $sp-1; flex-wrap: wrap; margin-bottom: $sp-2; }
.rl__chip {
padding: $sp-1 $sp-3;
border-radius: $r-pill;
border: 1px solid rgba(23, 20, 26, .18);
background: transparent;
color: rgba(23, 20, 26, .6);
font-weight: 600; font-size: 10px; line-height: 1.6; font-family: inherit;
cursor: pointer;
transition: background $duration $easing, color $duration $easing, border-color $duration $easing;
}
.rl__chip[aria-pressed="true"] {
background: $color;
border-color: $color;
color: $subject-cream;
}
.rl__list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: $sp-1; }
.rl__row {
position: relative;
box-sizing: border-box;
display: flex; align-items: center; gap: $sp-2;
padding: $sp-2 $sp-2 $sp-2 $sp-3;
border-radius: $r-sm;
background: rgba(23, 20, 26, .04);
overflow: hidden;
max-height: 40px;
opacity: 1;
transition: max-height $duration $easing, opacity $duration $easing, padding $duration $easing, margin $duration $easing;
}
.rl__row::before {
content: "";
position: absolute; left: 0; top: 0; bottom: 0;
width: 3px;
background: $subject-blue;
}
.rl__row.is-hidden { max-height: 0; opacity: 0; padding-top: 0; padding-bottom: 0; margin: 0; }
.rl__date { flex: 0 0 auto; font-weight: 700; font-size: 10px; line-height: 1; font-family: inherit; color: rgba($subject-ink, .72); }
.rl__title {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 600; font-size: clamp(11px, 3vw, 12px); line-height: 1.35; font-family: inherit;
color: $subject-ink;
}
.rl__tag {
flex: 0 0 auto;
padding: $sp-1 $sp-2;
border-radius: $r-xs;
background: rgba(47, 109, 246, .12);
color: $subject-blue-deep;
font-weight: 700; font-size: 9px; line-height: 1.6; font-family: inherit;
}
// ── 데모 루프: 행이 순서대로 살짝 밝아졌다 돌아온다(항상 재생, 클릭과 무관) ──────────────
.rl.is-demo .rl__row::before { animation: rl-scan 2s $easing infinite; }
.rl.is-demo .rl__row:nth-child(1)::before { animation-delay: 0ms; }
.rl.is-demo .rl__row:nth-child(2)::before { animation-delay: 160ms; }
.rl.is-demo .rl__row:nth-child(3)::before { animation-delay: 320ms; }
.rl.is-demo .rl__row:nth-child(4)::before { animation-delay: 480ms; }
@keyframes rl-scan {
0%, 80%, 100% { transform: scaleY(1); opacity: 1; }
30% { transform: scaleY(1.6); opacity: .5; }
}
// ── 반응형(BD9) ──────────────────────────────────────────
@media (max-width: 340px), (max-height: 220px) {
.rl { padding: $sp-1 $sp-3; }
.rl__filters { margin-bottom: $sp-1; }
.rl__date { display: none; }
.rl__row { padding: $sp-1 $sp-2 $sp-1 $sp-3; max-height: 26px; }
}
// ── 접근성 ──────────────────────────────────────────
@media (prefers-reduced-motion: reduce) {
.rl__row { transition: none; }
.rl.is-demo .rl__row::before { 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);
}
.rl {
box-sizing: border-box;
width: min(400px, 94vw);
background: #fff7e6;
border-radius: 16px;
padding: 8px 16px;
}
.rl__filters {
display: flex;
gap: 4px;
flex-wrap: wrap;
margin-bottom: 8px;
}
.rl__chip {
padding: 4px 12px;
border-radius: 999px;
border: 1px solid rgba(23, 20, 26, 0.18);
background: transparent;
color: rgba(23, 20, 26, 0.6);
font-weight: 600;
font-size: 10px;
line-height: 1.6;
font-family: inherit;
cursor: pointer;
transition: background 300ms cubic-bezier(0.2, 0.8, 0.2, 1), color 300ms cubic-bezier(0.2, 0.8, 0.2, 1), border-color 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.rl__chip[aria-pressed=true] {
background: #17141a;
border-color: #17141a;
color: #fff7e6;
}
.rl__list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.rl__row {
position: relative;
box-sizing: border-box;
display: flex;
align-items: center;
gap: 8px;
padding: 8px 8px 8px 12px;
border-radius: 8px;
background: rgba(23, 20, 26, 0.04);
overflow: hidden;
max-height: 40px;
opacity: 1;
transition: max-height 300ms cubic-bezier(0.2, 0.8, 0.2, 1), opacity 300ms cubic-bezier(0.2, 0.8, 0.2, 1), padding 300ms cubic-bezier(0.2, 0.8, 0.2, 1), margin 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.rl__row::before {
content: "";
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 3px;
background: #2f6df6;
}
.rl__row.is-hidden {
max-height: 0;
opacity: 0;
padding-top: 0;
padding-bottom: 0;
margin: 0;
}
.rl__date {
flex: 0 0 auto;
font-weight: 700;
font-size: 10px;
line-height: 1;
font-family: inherit;
color: rgba(23, 20, 26, 0.72);
}
.rl__title {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 600;
font-size: clamp(11px, 3vw, 12px);
line-height: 1.35;
font-family: inherit;
color: #17141a;
}
.rl__tag {
flex: 0 0 auto;
padding: 4px 8px;
border-radius: 4px;
background: rgba(47, 109, 246, 0.12);
color: #0a4fe8;
font-weight: 700;
font-size: 9px;
line-height: 1.6;
font-family: inherit;
}
.rl.is-demo .rl__row::before {
animation: rl-scan 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.rl.is-demo .rl__row:nth-child(1)::before {
animation-delay: 0ms;
}
.rl.is-demo .rl__row:nth-child(2)::before {
animation-delay: 160ms;
}
.rl.is-demo .rl__row:nth-child(3)::before {
animation-delay: 320ms;
}
.rl.is-demo .rl__row:nth-child(4)::before {
animation-delay: 480ms;
}
@keyframes rl-scan {
0%, 80%, 100% {
transform: scaleY(1);
opacity: 1;
}
30% {
transform: scaleY(1.6);
opacity: 0.5;
}
}
@media (max-width: 340px), (max-height: 220px) {
.rl {
padding: 4px 12px;
}
.rl__filters {
margin-bottom: 4px;
}
.rl__date {
display: none;
}
.rl__row {
padding: 4px 8px 4px 12px;
max-height: 26px;
}
}
@media (prefers-reduced-motion: reduce) {
.rl__row {
transition: none;
}
.rl.is-demo .rl__row::before {
animation: none !important;
}
}// 실제 동작: 분류 칩을 클릭하면 그 분류가 아닌 행이 실제로 접히고 칩 상태가 배타적으로 바뀐다.
var root = document.querySelector('.rl');
var chips = root.querySelectorAll('.rl__chip');
var rows = root.querySelectorAll('.rl__row');
chips.forEach(function (chip) {
chip.addEventListener('click', function () {
root.classList.remove('is-demo');
chips.forEach(function (c) { c.setAttribute('aria-pressed', 'false'); });
chip.setAttribute('aria-pressed', 'true');
var cat = chip.getAttribute('data-cat');
rows.forEach(function (row) {
var show = cat === 'all' || row.getAttribute('data-cat') === cat;
row.classList.toggle('is-hidden', !show);
});
});
});import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./PressReleaseList.module.scss";
export type PressReleaseListProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Cat = "all" | "product" | "partner" | "award";
const CHIPS: { cat: Cat; label: string }[] = [
{ cat: "all", label: "전체" },
{ cat: "product", label: "제품" },
{ cat: "partner", label: "파트너십" },
{ cat: "award", label: "수상" },
];
const RELEASES: { date: string; title: string; cat: Exclude<Cat, "all">; tag: string }[] = [
{ date: "08.20", title: "새 워크스페이스 기능 출시", cat: "product", tag: "제품" },
{ date: "07.02", title: "B사와 유통 파트너십 체결", cat: "partner", tag: "파트너십" },
{ date: "06.15", title: "올해의 스타트업상 수상", cat: "award", tag: "수상" },
{ date: "05.30", title: "3.0 버전 업데이트 공개", cat: "product", tag: "제품" },
];
export default function PressReleaseList({ duration = 300, easing = "cubic-bezier(.2,.8,.2,1)", color = "#17141a" }: PressReleaseListProps) {
const [active, setActive] = useState<Cat>("all");
const [interacted, setInteracted] = useState(false);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<section className={interacted ? styles.rl : `${styles.rl} ${styles.demo}`} style={vars}>
<div className={styles.filters} role="group" aria-label="분류">
{CHIPS.map((c) => (
<button
key={c.cat}
type="button"
className={styles.chip}
aria-pressed={active === c.cat}
onClick={() => { setInteracted(true); setActive(c.cat); }}
>
{c.label}
</button>
))}
</div>
<ul className={styles.list}>
{RELEASES.map((r) => {
const show = active === "all" || active === r.cat;
return (
<li key={r.title} className={show ? styles.row : `${styles.row} ${styles.hidden}`}>
<span className={styles.date}>{r.date}</span>
<span className={styles.title}>{r.title}</span>
<span className={styles.tag}>{r.tag}</span>
</li>
);
})}
</ul>
</section>
);
}02 · 보도자료 목록 — 측정 누적면적 0.271% · 움직인 프레임 16/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
악센트 바의 스캔 동작
각 행 왼쪽 악센트 바가 순서대로 밝아지는 스캔 동작을 항상 반복합니다.
chips.forEach(function (chip) {
chip.addEventListener('click', function () {
root.classList.remove('is-demo');
chips.forEach(function (c) { c.setAttribute('aria-pressed', 'false'); });
chip.setAttribute('aria-pressed', 'true');
var cat = chip.getAttribute('data-cat');
rows.forEach(function (row) {
var show = cat === 'all' || row.getAttribute('data-cat') === cat;
row.classList.toggle('is-hidden', !show);
});
});
});
03미디어 키트 다운로드
고른 파일만 받는다
로고 팩·브랜드 가이드·제품 스크린샷 중 원하는 파일을 체크하면 "선택한 파일 받기" 버튼이 실제로 활성화됩니다.

<main class="stage">
<section class="mk">
<h2 class="mk__title">미디어 키트</h2>
<ul class="mk__list">
<li class="mk__row">
<label class="mk__label">
<input class="mk__check" type="checkbox" value="logo">
<span class="mk__name">로고 팩 (SVG · PNG)</span>
</label>
<span class="mk__size">1.2MB</span>
<svg class="mk__done-ic" viewBox="0 0 20 20" aria-hidden="true"><polyline points="4,10 8,14 16,5"/></svg>
</li>
<li class="mk__row">
<label class="mk__label">
<input class="mk__check" type="checkbox" value="guide">
<span class="mk__name">브랜드 가이드 (PDF)</span>
</label>
<span class="mk__size">3.4MB</span>
<svg class="mk__done-ic" viewBox="0 0 20 20" aria-hidden="true"><polyline points="4,10 8,14 16,5"/></svg>
</li>
<li class="mk__row">
<label class="mk__label">
<input class="mk__check" type="checkbox" value="shots">
<span class="mk__name">제품 스크린샷 (ZIP)</span>
</label>
<span class="mk__size">4.8MB</span>
<svg class="mk__done-ic" viewBox="0 0 20 20" aria-hidden="true"><polyline points="4,10 8,14 16,5"/></svg>
</li>
</ul>
<button class="mk__btn" type="button" disabled>
<span class="mk__btn-ic" aria-hidden="true"></span>
선택한 파일 받기
</button>
<p class="mk__status" role="status" aria-live="polite"></p>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-base; // 체크·버튼 전환
$easing: $ease-pop;
$color: $subject-blue; // 체크·강조색
@include stage($stage-orange);
.mk {
position: relative;
box-sizing: border-box;
overflow: hidden;
width: min(360px, 94vw);
background: $subject-cream;
border-radius: $r-card;
padding: $sp-3;
}
.mk::before {
content: "";
position: absolute; left: 0; right: 0; top: 0;
height: 3px;
background: $color;
transform-origin: center;
animation: mk-scan 2s $easing infinite;
}
@keyframes mk-scan {
0%, 70%, 100% { transform: scaleX(.25); opacity: .5; }
30% { transform: scaleX(1); opacity: 1; }
}
.mk__title { margin: 0 0 $sp-3; font-weight: 800; font-size: clamp(14px, 4vw, 16px); line-height: 1.2; font-family: inherit; color: $subject-ink; }
.mk__list { list-style: none; margin: 0 0 $sp-3; padding: 0; display: flex; flex-direction: column; gap: $sp-1; }
.mk__row {
display: flex; align-items: center; gap: $sp-2;
padding: $sp-1 $sp-2;
border-radius: $r-sm;
background: rgba(23, 20, 26, .05);
}
.mk__label { display: flex; align-items: center; gap: $sp-2; flex: 1 1 auto; min-width: 0; cursor: pointer; }
.mk__check { width: 16px; height: 16px; accent-color: $color; margin: 0; flex: 0 0 auto; }
.mk__name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 600; font-size: clamp(10px, 3vw, 11px); line-height: 1.3; font-family: inherit;
color: $subject-ink;
}
.mk__size { flex: 0 0 auto; font-weight: 600; font-size: 9px; line-height: 1; font-family: inherit; color: rgba($subject-ink, .72); }
.mk__done-ic {
width: 14px; height: 14px; flex: 0 0 auto;
fill: none;
stroke: $color;
stroke-width: 2.5;
stroke-linecap: round;
stroke-linejoin: round;
stroke-dasharray: 20;
stroke-dashoffset: 20;
opacity: 0;
transition: stroke-dashoffset $duration $easing, opacity $duration $easing;
}
.mk__row.is-done .mk__done-ic { stroke-dashoffset: 0; opacity: 1; }
.mk__row.is-done .mk__size { display: none; }
.mk__btn {
display: flex; align-items: center; justify-content: center;
width: 100%;
padding: $sp-2 $sp-4;
border-radius: $r-control;
border: 0;
background: $subject-ink;
color: $subject-cream;
font-weight: 700; font-size: 12px; line-height: 1.4; font-family: inherit;
cursor: pointer;
transition: opacity $duration $easing;
}
.mk__btn:disabled { opacity: .4; cursor: not-allowed; }
.mk__btn-ic {
width: 0; height: 0;
border-left: 5px solid transparent;
border-right: 5px solid transparent;
border-top: 6px solid currentColor;
margin-right: $sp-1;
animation: mk-bounce 2s ease-in-out infinite;
}
@keyframes mk-bounce {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(3px); }
}
.mk__status { min-height: 14px; margin: $sp-2 0 0; font-weight: 600; font-size: 11px; line-height: 1.4; font-family: inherit; color: $color; text-align: center; }
// ── 반응형(BD9) ──────────────────────────────────────────
@media (max-width: 340px), (max-height: 220px) {
.mk { padding: $sp-2; }
.mk__title { display: none; }
.mk__list { margin-bottom: $sp-1; }
.mk__size { display: none; }
.mk__row { padding: $sp-1 $sp-2; }
.mk__btn { padding: $sp-1 $sp-3; }
.mk__status { margin-top: $sp-1; min-height: 0; }
}
// ── 접근성 ──────────────────────────────────────────
@media (prefers-reduced-motion: reduce) {
.mk__btn-ic { animation: none !important; }
.mk::before { animation: none !important; }
.mk__done-ic, .mk__btn { transition: none; }
}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);
}
.mk {
position: relative;
box-sizing: border-box;
overflow: hidden;
width: min(360px, 94vw);
background: #fff7e6;
border-radius: 16px;
padding: 12px;
}
.mk::before {
content: "";
position: absolute;
left: 0;
right: 0;
top: 0;
height: 3px;
background: #2f6df6;
transform-origin: center;
animation: mk-scan 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
@keyframes mk-scan {
0%, 70%, 100% {
transform: scaleX(0.25);
opacity: 0.5;
}
30% {
transform: scaleX(1);
opacity: 1;
}
}
.mk__title {
margin: 0 0 12px;
font-weight: 800;
font-size: clamp(14px, 4vw, 16px);
line-height: 1.2;
font-family: inherit;
color: #17141a;
}
.mk__list {
list-style: none;
margin: 0 0 12px;
padding: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.mk__row {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 8px;
border-radius: 8px;
background: rgba(23, 20, 26, 0.05);
}
.mk__label {
display: flex;
align-items: center;
gap: 8px;
flex: 1 1 auto;
min-width: 0;
cursor: pointer;
}
.mk__check {
width: 16px;
height: 16px;
accent-color: #2f6df6;
margin: 0;
flex: 0 0 auto;
}
.mk__name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 600;
font-size: clamp(10px, 3vw, 11px);
line-height: 1.3;
font-family: inherit;
color: #17141a;
}
.mk__size {
flex: 0 0 auto;
font-weight: 600;
font-size: 9px;
line-height: 1;
font-family: inherit;
color: rgba(23, 20, 26, 0.72);
}
.mk__done-ic {
width: 14px;
height: 14px;
flex: 0 0 auto;
fill: none;
stroke: #2f6df6;
stroke-width: 2.5;
stroke-linecap: round;
stroke-linejoin: round;
stroke-dasharray: 20;
stroke-dashoffset: 20;
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);
}
.mk__row.is-done .mk__done-ic {
stroke-dashoffset: 0;
opacity: 1;
}
.mk__row.is-done .mk__size {
display: none;
}
.mk__btn {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
padding: 8px 16px;
border-radius: 12px;
border: 0;
background: #17141a;
color: #fff7e6;
font-weight: 700;
font-size: 12px;
line-height: 1.4;
font-family: inherit;
cursor: pointer;
transition: opacity 300ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.mk__btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.mk__btn-ic {
width: 0;
height: 0;
border-left: 5px solid transparent;
border-right: 5px solid transparent;
border-top: 6px solid currentColor;
margin-right: 4px;
animation: mk-bounce 2s ease-in-out infinite;
}
@keyframes mk-bounce {
0%, 100% {
transform: translateY(0);
}
50% {
transform: translateY(3px);
}
}
.mk__status {
min-height: 14px;
margin: 8px 0 0;
font-weight: 600;
font-size: 11px;
line-height: 1.4;
font-family: inherit;
color: #2f6df6;
text-align: center;
}
@media (max-width: 340px), (max-height: 220px) {
.mk {
padding: 8px;
}
.mk__title {
display: none;
}
.mk__list {
margin-bottom: 4px;
}
.mk__size {
display: none;
}
.mk__row {
padding: 4px 8px;
}
.mk__btn {
padding: 4px 12px;
}
.mk__status {
margin-top: 4px;
min-height: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.mk__btn-ic {
animation: none !important;
}
.mk::before {
animation: none !important;
}
.mk__done-ic, .mk__btn {
transition: none;
}
}// 실제 동작: 체크한 파일 수만큼 버튼 라벨이 바뀌고, 1개 이상이어야 버튼이 눌린다.
var root = document.querySelector('.mk');
var checks = root.querySelectorAll('.mk__check');
var btn = root.querySelector('.mk__btn');
var status = root.querySelector('.mk__status');
function updateLabel() {
var checkedFiles = Array.prototype.filter.call(checks, function (c) { return c.checked; });
btn.disabled = checkedFiles.length === 0;
btn.lastChild.textContent = checkedFiles.length ? ' 선택한 파일 받기 (' + checkedFiles.length + ')' : ' 선택한 파일 받기';
}
checks.forEach(function (c) { c.addEventListener('change', updateLabel); });
btn.addEventListener('click', function () {
var checkedFiles = Array.prototype.filter.call(checks, function (c) { return c.checked; });
checkedFiles.forEach(function (c) { c.closest('.mk__row').classList.add('is-done'); });
status.textContent = '파일 ' + checkedFiles.length + '개를 받았습니다.';
});import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./PressMediaKit.module.scss";
export type PressMediaKitProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const FILES = [
{ id: "logo", name: "로고 팩 (SVG · PNG)", size: "1.2MB" },
{ id: "guide", name: "브랜드 가이드 (PDF)", size: "3.4MB" },
{ id: "shots", name: "제품 스크린샷 (ZIP)", size: "4.8MB" },
];
export default function PressMediaKit({ duration = 300, easing = "cubic-bezier(.2,1.4,.4,1)", color = "#2f6df6" }: PressMediaKitProps) {
const [checked, setChecked] = useState<Record<string, boolean>>({});
const [done, setDone] = useState<Record<string, boolean>>({});
const [status, setStatus] = useState("");
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const count = Object.values(checked).filter(Boolean).length;
function toggle(id: string, e: any) {
setChecked((c) => ({ ...c, [id]: e.target.checked }));
}
function download() {
const next: Record<string, boolean> = {};
FILES.forEach((f) => { if (checked[f.id]) next[f.id] = true; });
setDone((d) => ({ ...d, ...next }));
setStatus(`파일 ${count}개를 받았습니다.`);
}
return (
<section className={styles.mk} style={vars}>
<h2 className={styles.title}>미디어 키트</h2>
<ul className={styles.list}>
{FILES.map((f) => (
<li key={f.id} className={done[f.id] ? `${styles.row} ${styles.done}` : styles.row}>
<label className={styles.label}>
<input className={styles.check} type="checkbox" checked={!!checked[f.id]} onChange={(e: any) => toggle(f.id, e)} />
<span className={styles.name}>{f.name}</span>
</label>
{!done[f.id] && <span className={styles.size}>{f.size}</span>}
<svg className={styles.doneIc} viewBox="0 0 20 20" aria-hidden="true"><polyline points="4,10 8,14 16,5" /></svg>
</li>
))}
</ul>
<button type="button" className={styles.btn} disabled={count === 0} onClick={download}>
<span className={styles.btnIc} aria-hidden="true" />
{count ? ` 선택한 파일 받기 (${count})` : " 선택한 파일 받기"}
</button>
<p className={styles.status} role="status" aria-live="polite">{status}</p>
</section>
);
}03 · 미디어 키트 다운로드 — 측정 누적면적 0.756% · 움직인 프레임 22/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
누르면 무엇이 바뀌나
누르면 선택한 파일마다 체크 아이콘이 그려지고 받은 개수가 문구로 안내됩니다.
function updateLabel() {
var checkedFiles = Array.prototype.filter.call(checks, function (c) { return c.checked; });
btn.disabled = checkedFiles.length === 0;
btn.lastChild.textContent = checkedFiles.length ? ' 선택한 파일 받기 (' + checkedFiles.length + ')' : ' 선택한 파일 받기';
}
checks.forEach(function (c) { c.addEventListener('change', updateLabel); });
btn.addEventListener('click', function () {
var checkedFiles = Array.prototype.filter.call(checks, function (c) { return c.checked; });
checkedFiles.forEach(function (c) { c.closest('.mk__row').classList.add('is-done'); });
status.textContent = '파일 ' + checkedFiles.length + '개를 받았습니다.';
});
04언론 보도 로고 바
여덟 개 마크가 끊김 없이
도형으로 그린 언론사 마크 여덟 개(실제 로고 아님)가 한 줄로 이어져 끊김 없이 흘러갑니다.

<main class="stage">
<section class="mb" aria-label="언론 보도">
<p class="mb__label">AS SEEN IN</p>
<div class="mb__viewport">
<div class="mb__track">
<span class="mb__logo mb__logo--1" aria-hidden="true"></span>
<span class="mb__logo mb__logo--2" aria-hidden="true"></span>
<span class="mb__logo mb__logo--3" aria-hidden="true"></span>
<span class="mb__logo mb__logo--4" aria-hidden="true"></span>
<span class="mb__logo mb__logo--1" aria-hidden="true"></span>
<span class="mb__logo mb__logo--2" aria-hidden="true"></span>
<span class="mb__logo mb__logo--3" aria-hidden="true"></span>
<span class="mb__logo mb__logo--4" aria-hidden="true"></span>
</div>
</div>
<button class="mb__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-paper);
.mb { width: min(440px, 96vw); text-align: center; }
.mb__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);
}
.mb__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 로 간격을 준다
.mb__track {
display: flex;
width: max-content;
animation: mb-scroll $duration $easing infinite;
}
.mb.is-paused .mb__track,
.mb__viewport:hover .mb__track { animation-play-state: paused; }
@keyframes mb-scroll {
from { transform: translateX(0); }
to { transform: translateX(-50%); }
}
.mb__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;
}
.mb__logo::before {
content: "";
width: 12px; height: 12px;
background: $color;
}
.mb__logo::after {
content: "";
width: 20px; height: 6px;
border-radius: $r-xs;
background: rgba(23, 20, 26, .28);
}
.mb__logo--1::before { border-radius: 50%; }
.mb__logo--2::before { border-radius: $r-xs; transform: rotate(45deg); }
.mb__logo--3::before { clip-path: polygon(50% 0%, 0% 100%, 100% 100%); border-radius: 0; }
.mb__logo--4::before { border-radius: $r-pill; width: 16px; }
.mb__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(23, 20, 26, .6);
font-weight: 600; font-size: 10px; line-height: 1.6; font-family: inherit;
cursor: pointer;
}
.mb__pause[aria-pressed="true"] { background: rgba(23, 20, 26, .08); color: $subject-ink; }
// ── 접근성 ──────────────────────────────────────────
@media (prefers-reduced-motion: reduce) {
.mb__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: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.mb {
width: min(440px, 96vw);
text-align: center;
}
.mb__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);
}
.mb__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);
}
.mb__track {
display: flex;
width: max-content;
animation: mb-scroll 14s linear infinite;
}
.mb.is-paused .mb__track,
.mb__viewport:hover .mb__track {
animation-play-state: paused;
}
@keyframes mb-scroll {
from {
transform: translateX(0);
}
to {
transform: translateX(-50%);
}
}
.mb__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;
}
.mb__logo::before {
content: "";
width: 12px;
height: 12px;
background: #17141a;
}
.mb__logo::after {
content: "";
width: 20px;
height: 6px;
border-radius: 4px;
background: rgba(23, 20, 26, 0.28);
}
.mb__logo--1::before {
border-radius: 50%;
}
.mb__logo--2::before {
border-radius: 4px;
transform: rotate(45deg);
}
.mb__logo--3::before {
clip-path: polygon(50% 0%, 0% 100%, 100% 100%);
border-radius: 0;
}
.mb__logo--4::before {
border-radius: 999px;
width: 16px;
}
.mb__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.6);
font-weight: 600;
font-size: 10px;
line-height: 1.6;
font-family: inherit;
cursor: pointer;
}
.mb__pause[aria-pressed=true] {
background: rgba(23, 20, 26, 0.08);
color: #17141a;
}
@media (prefers-reduced-motion: reduce) {
.mb__track {
animation: none !important;
transform: translateX(0);
}
}// 실제 동작: 버튼을 누르거나 마우스를 올리면 흐름이 실제로 멈춘다(터치 기기는 버튼으로).
var root = document.querySelector('.mb');
var btn = document.querySelector('.mb__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 "./PressMentionsBar.module.scss";
const SHAPES = ["circle", "diamond", "triangle", "pill"] as const;
export type PressMentionsBarProps = { duration?: number; easing?: string; color?: string };
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function PressMentionsBar({ duration = 14000, easing = "linear", color = "#17141a" }: PressMentionsBarProps) {
const [paused, setPaused] = useState(false);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const logos = [...SHAPES, ...SHAPES];
return (
<section className={styles.mb} style={vars} aria-label="언론 보도">
<p className={styles.label}>AS SEEN IN</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>
);
}04 · 언론 보도 로고 바 — 측정 누적면적 8.057% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
올리면 그 자리에서 멈춘다
커서를 올리거나 옆 버튼을 눌러 두면 흐름이 그 지점에서 정지합니다.
// 트랙 자체엔 gap 을 쓰지 않는다 — 항목마다 margin-right 로 간격을 준다
.mb__track { display: flex; width: max-content; animation: mb-scroll 14s linear infinite; }
.mb__logo { flex: 0 0 auto; margin-right: $sp-6; }
@keyframes mb-scroll {
from { transform: translateX(0); }
to { transform: translateX(-50%); }
}
05숫자 하이라이트
0에서 목표값까지
카드 세 장의 큰 숫자가 페이지를 열자마자 0에서 실제 목표값(언론 보도 건수·협력 매체 수·누적 다운로드)까지 실제로 올라가며 멈춥니다.

<main class="stage">
<section class="sc">
<div class="sc__card">
<p class="sc__num" data-target="128">0</p>
<p class="sc__label">언론 보도 건수</p>
</div>
<div class="sc__card">
<p class="sc__num" data-target="42">0</p>
<p class="sc__label">협력 매체 수</p>
</div>
<div class="sc__card">
<p class="sc__num" data-target="3200">0</p>
<p class="sc__label">누적 다운로드</p>
</div>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-enter; // 카드 악센트 펄스
$easing: $ease-spring;
$color: $subject-blue; // 숫자·악센트 색
@include stage($stage-ink);
.sc {
display: flex;
gap: $sp-3;
width: min(420px, 94vw);
}
.sc__card {
position: relative;
flex: 0 0 auto;
box-sizing: border-box;
width: calc((100% - #{$sp-3} * 2) / 3);
overflow: hidden;
border-radius: $r-card;
padding: $sp-4 $sp-2 $sp-3;
background: rgba(255, 247, 230, .06);
text-align: center;
}
.sc__card::before {
content: "";
position: absolute; left: 0; right: 0; top: 0;
height: 3px;
background: $color;
transform-origin: center;
animation: sc-pulse 2s $easing infinite;
}
.sc__card:nth-child(1)::before { animation-delay: 0ms; }
.sc__card:nth-child(2)::before { animation-delay: 220ms; }
.sc__card:nth-child(3)::before { animation-delay: 440ms; }
@keyframes sc-pulse {
0%, 70%, 100% { transform: scaleX(.3); opacity: .5; }
30% { transform: scaleX(1); opacity: 1; }
}
.sc__num {
margin: 0 0 $sp-1;
font-weight: 800; font-size: clamp(20px, 7vw, 28px); line-height: 1; font-family: inherit;
color: $subject-cream;
font-variant-numeric: tabular-nums;
}
.sc__label {
margin: 0;
font-weight: 600; font-size: clamp(9px, 2.6vw, 10px); line-height: 1.3; font-family: inherit;
color: rgba(255, 247, 230, .58);
}
// ── 반응형(BD9) ──────────────────────────────────────────
@media (max-width: 340px), (max-height: 220px) {
.sc { gap: $sp-2; }
.sc__card { padding: $sp-2 $sp-1; }
.sc__num { font-size: 16px; }
.sc__label { font-size: 8px; }
}
// ── 접근성 ──────────────────────────────────────────
@media (prefers-reduced-motion: reduce) {
.sc__card::before { animation: none !important; transform: scaleX(1); opacity: 1; }
}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);
}
.sc {
display: flex;
gap: 12px;
width: min(420px, 94vw);
}
.sc__card {
position: relative;
flex: 0 0 auto;
box-sizing: border-box;
width: calc((100% - 12px * 2) / 3);
overflow: hidden;
border-radius: 16px;
padding: 16px 8px 12px;
background: rgba(255, 247, 230, 0.06);
text-align: center;
}
.sc__card::before {
content: "";
position: absolute;
left: 0;
right: 0;
top: 0;
height: 3px;
background: #2f6df6;
transform-origin: center;
animation: sc-pulse 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.sc__card:nth-child(1)::before {
animation-delay: 0ms;
}
.sc__card:nth-child(2)::before {
animation-delay: 220ms;
}
.sc__card:nth-child(3)::before {
animation-delay: 440ms;
}
@keyframes sc-pulse {
0%, 70%, 100% {
transform: scaleX(0.3);
opacity: 0.5;
}
30% {
transform: scaleX(1);
opacity: 1;
}
}
.sc__num {
margin: 0 0 4px;
font-weight: 800;
font-size: clamp(20px, 7vw, 28px);
line-height: 1;
font-family: inherit;
color: #fff7e6;
font-variant-numeric: tabular-nums;
}
.sc__label {
margin: 0;
font-weight: 600;
font-size: clamp(9px, 2.6vw, 10px);
line-height: 1.3;
font-family: inherit;
color: rgba(255, 247, 230, 0.58);
}
@media (max-width: 340px), (max-height: 220px) {
.sc {
gap: 8px;
}
.sc__card {
padding: 8px 4px;
}
.sc__num {
font-size: 16px;
}
.sc__label {
font-size: 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.sc__card::before {
animation: none !important;
transform: scaleX(1);
opacity: 1;
}
}// 실제 동작: 페이지가 열리면 0에서 실제 목표값까지 숫자가 실시간으로 올라간다.
var nums = document.querySelectorAll('.sc__num');
var countStart = performance.now();
var countDuration = 1200;
function easeOut(t) { return 1 - Math.pow(1 - t, 3); }
function tick(now) {
var t = Math.min(1, (now - countStart) / countDuration);
var e = easeOut(t);
nums.forEach(function (el) {
var target = Number(el.getAttribute('data-target'));
el.textContent = Math.round(target * e).toLocaleString('ko-KR');
});
if (t < 1) requestAnimationFrame(tick);
}
requestAnimationFrame(tick);import { useEffect, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./PressStatCounter.module.scss";
export type PressStatCounterProps = { duration?: number; easing?: string; color?: string };
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const STATS = [
{ target: 128, label: "언론 보도 건수" },
{ target: 42, label: "협력 매체 수" },
{ target: 3200, label: "누적 다운로드" },
];
export default function PressStatCounter({ duration = 600, easing = "cubic-bezier(.2,.8,.2,1)", color = "#2f6df6" }: PressStatCounterProps) {
const [values, setValues] = useState<number[]>(STATS.map(() => 0));
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
useEffect(() => {
const start = performance.now();
const length = 1200;
let frame: number;
function easeOut(t: number) { return 1 - Math.pow(1 - t, 3); }
function tick(now: number) {
const t = Math.min(1, (now - start) / length);
const e = easeOut(t);
setValues(STATS.map((s) => Math.round(s.target * e)));
if (t < 1) frame = requestAnimationFrame(tick);
}
frame = requestAnimationFrame(tick);
return () => cancelAnimationFrame(frame);
}, []);
return (
<section className={styles.sc} style={vars}>
{STATS.map((s, i) => (
<div key={s.label} className={styles.card}>
<p className={styles.num}>{values[i].toLocaleString("ko-KR")}</p>
<p className={styles.label}>{s.label}</p>
</div>
))}
</section>
);
}05 · 숫자 하이라이트 — 측정 누적면적 2.675% · 움직인 프레임 21/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
카드 위 악센트 바
카드 위 악센트 바는 순서대로 스캔 동작을 계속 반복합니다.
var countStart = performance.now();
var countDuration = 1200;
function easeOut(t) { return 1 - Math.pow(1 - t, 3); }
function tick(now) {
var t = Math.min(1, (now - countStart) / countDuration);
var e = easeOut(t);
nums.forEach(function (el) {
var target = Number(el.getAttribute('data-target'));
el.textContent = Math.round(target * e).toLocaleString('ko-KR');
});
if (t < 1) requestAnimationFrame(tick);
}
requestAnimationFrame(tick);
06미디어 문의 카드
이메일이 실제로 복사된다
미디어 담당자 이름·직함 아래 이메일 버튼을 누르면 실제로 클립보드에 복사됩니다.

<main class="stage">
<section class="mc is-demo">
<span class="mc__orb mc__orb--1" aria-hidden="true"></span>
<span class="mc__orb mc__orb--2" aria-hidden="true"></span>
<span class="mc__avatar" aria-hidden="true"></span>
<p class="mc__name">김프레스</p>
<p class="mc__role">PR · 미디어 담당</p>
<button class="mc__copy" type="button" data-email="[email protected]">
<span class="mc__copy-label">[email protected]</span>
<svg class="mc__copy-ic" viewBox="0 0 20 20" aria-hidden="true"><polyline points="4,10 8,14 16,5"/></svg>
</button>
<p class="mc__hint" role="status" aria-live="polite"></p>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-base; // 복사 버튼 전환
$easing: $ease-pop;
$color: $subject-blue; // 버튼·체크 색
@include stage($stage-yellow);
.mc {
position: relative;
box-sizing: border-box; // width 에 패딩을 포함 — 아니면 320px 에서 패딩만큼 넘친다(실측 2026-09-10)
width: min(352px, 92vw); // 320 + 좌우패딩(sp-4×2=32) — border-box 전환 전 데스크톱 박스 크기를 그대로 유지(92vw 쪽은 그대로라 모바일엔 영향 없음)
text-align: center;
overflow: hidden;
isolation: isolate;
border-radius: $r-card;
padding: $sp-5 $sp-4 $sp-4;
}
.mc__orb { position: absolute; border-radius: 50%; filter: blur(16px); z-index: -1; }
.mc__orb--1 { width: 150px; height: 150px; left: -40px; top: -30px; background: radial-gradient(circle, $subject-blue, transparent 68%); opacity: .5; animation: mc-drift 8s ease-in-out infinite; }
.mc__orb--2 { width: 130px; height: 130px; right: -30px; bottom: -40px; background: radial-gradient(circle, $subject-ink, transparent 68%); opacity: .32; animation: mc-drift 7s ease-in-out infinite reverse; }
@keyframes mc-drift {
0%, 100% { transform: translate(0, 0) scale(1); }
50% { transform: translate($sp-6, -$sp-5) scale(1.25); }
}
.mc__avatar {
display: block;
width: 48px; height: 48px;
margin: 0 auto $sp-2;
border-radius: 50%;
background: $subject-ink;
}
.mc__name { margin: 0 0 $sp-1; font-weight: 800; font-size: clamp(14px, 4vw, 16px); line-height: 1.2; font-family: inherit; color: $subject-ink; }
.mc__role { margin: 0 0 $sp-4; font-weight: 500; font-size: clamp(10px, 2.8vw, 11px); line-height: 1.4; font-family: inherit; color: rgba($subject-ink, .72); }
.mc__copy {
display: inline-flex; align-items: center; gap: $sp-1;
padding: $sp-2 $sp-4;
border-radius: $r-control;
border: 1px solid rgba(23, 20, 26, .16);
background: none;
color: $subject-ink;
font-weight: 700; font-size: 11px; line-height: 1; font-family: inherit;
cursor: pointer;
max-width: 100%;
transition: border-color $duration $easing, background $duration $easing;
}
.mc__copy-ic {
width: 12px; height: 12px; flex: 0 0 auto;
fill: none;
stroke: $color;
stroke-width: 2.5;
stroke-linecap: round;
stroke-linejoin: round;
stroke-dasharray: 20;
stroke-dashoffset: 20;
opacity: 0;
transition: stroke-dashoffset $duration $easing, opacity $duration $easing;
}
.mc__copy.is-copied { border-color: $color; background: rgba(47, 109, 246, .08); }
.mc__copy.is-copied .mc__copy-ic { stroke-dashoffset: 0; opacity: 1; }
.mc__hint { min-height: 14px; margin: $sp-2 0 0; font-weight: 600; font-size: 10px; line-height: 1.4; font-family: inherit; color: $color; }
// ── 데모 루프: 복사 버튼을 클릭하기 전까지 체크가 옅게 나타났다 사라진다(항상 재생) ──────
.mc.is-demo .mc__copy-ic { animation: mc-hint 2s $easing infinite; }
@keyframes mc-hint {
0%, 60% { stroke-dashoffset: 20; opacity: 0; }
78%, 90% { stroke-dashoffset: 0; opacity: .5; }
100% { stroke-dashoffset: 20; opacity: 0; }
}
// ── 반응형(BD9) ──────────────────────────────────────────
@media (max-width: 340px), (max-height: 220px) {
.mc { padding: $sp-3 $sp-3; }
.mc__avatar { width: 36px; height: 36px; margin-bottom: $sp-1; }
.mc__role { margin-bottom: $sp-2; }
.mc__copy { padding: $sp-1 $sp-3; font-size: 10px; }
}
// ── 접근성 ──────────────────────────────────────────
@media (prefers-reduced-motion: reduce) {
.mc__orb { animation: none !important; }
.mc__copy, .mc__copy-ic { transition: none; }
.mc.is-demo .mc__copy-ic { 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);
}
.mc {
position: relative;
box-sizing: border-box;
width: min(352px, 92vw);
text-align: center;
overflow: hidden;
isolation: isolate;
border-radius: 16px;
padding: 20px 16px 16px;
}
.mc__orb {
position: absolute;
border-radius: 50%;
filter: blur(16px);
z-index: -1;
}
.mc__orb--1 {
width: 150px;
height: 150px;
left: -40px;
top: -30px;
background: radial-gradient(circle, #2f6df6, transparent 68%);
opacity: 0.5;
animation: mc-drift 8s ease-in-out infinite;
}
.mc__orb--2 {
width: 130px;
height: 130px;
right: -30px;
bottom: -40px;
background: radial-gradient(circle, #17141a, transparent 68%);
opacity: 0.32;
animation: mc-drift 7s ease-in-out infinite reverse;
}
@keyframes mc-drift {
0%, 100% {
transform: translate(0, 0) scale(1);
}
50% {
transform: translate(24px, -20px) scale(1.25);
}
}
.mc__avatar {
display: block;
width: 48px;
height: 48px;
margin: 0 auto 8px;
border-radius: 50%;
background: #17141a;
}
.mc__name {
margin: 0 0 4px;
font-weight: 800;
font-size: clamp(14px, 4vw, 16px);
line-height: 1.2;
font-family: inherit;
color: #17141a;
}
.mc__role {
margin: 0 0 16px;
font-weight: 500;
font-size: clamp(10px, 2.8vw, 11px);
line-height: 1.4;
font-family: inherit;
color: rgba(23, 20, 26, 0.72);
}
.mc__copy {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 8px 16px;
border-radius: 12px;
border: 1px solid rgba(23, 20, 26, 0.16);
background: none;
color: #17141a;
font-weight: 700;
font-size: 11px;
line-height: 1;
font-family: inherit;
cursor: pointer;
max-width: 100%;
transition: border-color 300ms cubic-bezier(0.2, 1.4, 0.4, 1), background 300ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.mc__copy-ic {
width: 12px;
height: 12px;
flex: 0 0 auto;
fill: none;
stroke: #2f6df6;
stroke-width: 2.5;
stroke-linecap: round;
stroke-linejoin: round;
stroke-dasharray: 20;
stroke-dashoffset: 20;
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);
}
.mc__copy.is-copied {
border-color: #2f6df6;
background: rgba(47, 109, 246, 0.08);
}
.mc__copy.is-copied .mc__copy-ic {
stroke-dashoffset: 0;
opacity: 1;
}
.mc__hint {
min-height: 14px;
margin: 8px 0 0;
font-weight: 600;
font-size: 10px;
line-height: 1.4;
font-family: inherit;
color: #2f6df6;
}
.mc.is-demo .mc__copy-ic {
animation: mc-hint 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
@keyframes mc-hint {
0%, 60% {
stroke-dashoffset: 20;
opacity: 0;
}
78%, 90% {
stroke-dashoffset: 0;
opacity: 0.5;
}
100% {
stroke-dashoffset: 20;
opacity: 0;
}
}
@media (max-width: 340px), (max-height: 220px) {
.mc {
padding: 12px 12px;
}
.mc__avatar {
width: 36px;
height: 36px;
margin-bottom: 4px;
}
.mc__role {
margin-bottom: 8px;
}
.mc__copy {
padding: 4px 12px;
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.mc__orb {
animation: none !important;
}
.mc__copy, .mc__copy-ic {
transition: none;
}
.mc.is-demo .mc__copy-ic {
animation: none !important;
}
}// 실제 동작: 버튼을 누르면 이메일이 실제로 클립보드에 복사되고, 잠깐 체크 아이콘으로 바뀐 뒤 되돌아간다.
var root = document.querySelector('.mc');
var btn = document.querySelector('.mc__copy');
var hint = document.querySelector('.mc__hint');
var revertTimer = null;
btn.addEventListener('click', function () {
root.classList.remove('is-demo');
var email = btn.getAttribute('data-email');
var showCopied = function () {
btn.classList.add('is-copied');
hint.textContent = '이메일 주소를 복사했습니다.';
clearTimeout(revertTimer);
revertTimer = setTimeout(function () { btn.classList.remove('is-copied'); }, 1600);
};
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(email).then(showCopied, showCopied);
} else {
var t = document.createElement('textarea');
t.value = email; document.body.appendChild(t); t.select();
try { document.execCommand('copy'); } catch (e) {}
document.body.removeChild(t);
showCopied();
}
});import { useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./PressContact.module.scss";
export type PressContactProps = { duration?: number; easing?: string; color?: string };
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const EMAIL = "[email protected]";
export default function PressContact({ duration = 300, easing = "cubic-bezier(.2,1.4,.4,1)", color = "#2f6df6" }: PressContactProps) {
const [copied, setCopied] = useState(false);
const [hint, setHint] = useState("");
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
async function onCopy() {
try {
await navigator.clipboard.writeText(EMAIL);
} catch {
// 클립보드 권한이 없어도 상태는 그대로 갱신한다(폴백은 vanilla 판 참고)
}
setCopied(true);
setHint("이메일 주소를 복사했습니다.");
if (timer.current) clearTimeout(timer.current);
timer.current = setTimeout(() => setCopied(false), 1600);
}
return (
<section className={styles.mc} style={vars}>
<span className={`${styles.orb} ${styles.orb1}`} aria-hidden="true" />
<span className={`${styles.orb} ${styles.orb2}`} aria-hidden="true" />
<span className={styles.avatar} aria-hidden="true" />
<p className={styles.name}>김프레스</p>
<p className={styles.role}>PR · 미디어 담당</p>
<button type="button" className={copied ? `${styles.copy} ${styles.copied}` : styles.copy} onClick={onCopy}>
<span className={styles.copyLabel}>{EMAIL}</span>
<svg className={styles.copyIc} viewBox="0 0 20 20" aria-hidden="true"><polyline points="4,10 8,14 16,5" /></svg>
</button>
<p className={styles.hint} role="status" aria-live="polite">{hint}</p>
</section>
);
}06 · 미디어 문의 카드 — 측정 누적면적 3.774% · 움직인 프레임 6/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
1.6초 뒤 사라지는 체크
버튼엔 체크 아이콘이 잠깐 그려졌다가 1.6초 뒤 사라집니다.
btn.addEventListener('click', function () {
var email = btn.getAttribute('data-email');
var showCopied = function () {
btn.classList.add('is-copied');
hint.textContent = '이메일 주소를 복사했습니다.';
clearTimeout(revertTimer);
revertTimer = setTimeout(function () { btn.classList.remove('is-copied'); }, 1600);
};
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(email).then(showCopied, showCopied);
}
});
07창업자 인용
점 두 개로 오가는 인용문
창업자 인용문 두 개가 점 두 개로 오갑니다.

<main class="stage">
<section class="fq is-demo">
<span class="fq__mark" aria-hidden="true">"</span>
<div class="fq__stage">
<figure class="fq__slide is-active" data-i="0">
<blockquote class="fq__quote">"작은 팀도 대형 언론과 같은 속도로 소식을 전할 수 있어야 한다고 믿습니다."</blockquote>
<figcaption class="fq__who"><span class="fq__name">이도현</span> · 공동창업자 & CEO</figcaption>
</figure>
<figure class="fq__slide" data-i="1">
<blockquote class="fq__quote">"프레스룸은 홍보가 아니라 신뢰를 확인시켜주는 자리입니다."</blockquote>
<figcaption class="fq__who"><span class="fq__name">박서연</span> · 공동창업자 & CTO</figcaption>
</figure>
</div>
<div class="fq__dots" role="group" aria-label="인용문 선택">
<button class="fq__dot is-active" type="button" data-i="0" aria-selected="true" aria-label="첫 번째 인용"></button>
<button class="fq__dot" type="button" data-i="1" aria-selected="false" aria-label="두 번째 인용"></button>
</div>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-enter; // 슬라이드 크로스페이드
$easing: $ease-spring;
$color: $subject-ink; // 인용부호·강조색
@include stage($stage-orange);
.fq {
position: relative;
box-sizing: border-box; // width 에 패딩을 포함 — 아니면 320px 에서 패딩만큼 넘친다(실측 2026-09-10)
width: min(400px, 94vw);
background: $subject-cream;
border-radius: $r-card;
padding: $sp-6 $sp-4 $sp-4;
text-align: center;
overflow: hidden;
}
.fq__mark {
position: absolute;
top: $sp-1; left: 50%;
transform: translateX(-50%);
font: 800 56px/1 Georgia, serif;
color: rgba(47, 109, 246, .32);
animation: fq-wobble 2s $easing infinite;
}
@keyframes fq-wobble {
0%, 100% { transform: translateX(-50%) rotate(0deg) scale(1); }
50% { transform: translateX(-50%) rotate(-10deg) scale(1.35); }
}
.fq__stage { position: relative; min-height: 96px; }
.fq__slide {
position: absolute; inset: 0;
margin: 0;
opacity: 0;
transition: opacity $duration $easing;
pointer-events: none;
}
.fq__slide.is-active { opacity: 1; pointer-events: auto; }
.fq__quote {
margin: 0 0 $sp-2;
font-weight: 700; font-size: clamp(12px, 3.6vw, 14px); line-height: 1.5; font-family: inherit;
color: $subject-ink;
}
.fq__who { font-weight: 600; font-size: 11px; line-height: 1.4; font-family: inherit; color: rgba($subject-ink, .72); }
.fq__name { color: $color; }
.fq__dots { display: flex; justify-content: center; gap: $sp-2; margin-top: $sp-3; }
.fq__dot {
width: 8px; height: 8px;
border-radius: 50%;
border: 0;
padding: 0;
background: rgba(23, 20, 26, .2);
cursor: pointer;
transition: background $duration $easing, transform $duration $easing;
}
.fq__dot.is-active { background: $color; transform: scale(1.3); }
// ── 반응형(BD9) ──────────────────────────────────────────
@media (max-width: 340px), (max-height: 220px) {
.fq { padding: $sp-4 $sp-3 $sp-3; }
.fq__mark { font-size: 28px; }
.fq__stage { min-height: 70px; }
.fq__quote { font-size: 11px; }
}
// ── 접근성 ──────────────────────────────────────────
@media (prefers-reduced-motion: reduce) {
.fq__mark { animation: none !important; }
.fq__slide, .fq__dot { transition: none; }
}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 {
position: relative;
box-sizing: border-box;
width: min(400px, 94vw);
background: #fff7e6;
border-radius: 16px;
padding: 24px 16px 16px;
text-align: center;
overflow: hidden;
}
.fq__mark {
position: absolute;
top: 4px;
left: 50%;
transform: translateX(-50%);
font: 800 56px/1 Georgia, serif;
color: rgba(47, 109, 246, 0.32);
animation: fq-wobble 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes fq-wobble {
0%, 100% {
transform: translateX(-50%) rotate(0deg) scale(1);
}
50% {
transform: translateX(-50%) rotate(-10deg) scale(1.35);
}
}
.fq__stage {
position: relative;
min-height: 96px;
}
.fq__slide {
position: absolute;
inset: 0;
margin: 0;
opacity: 0;
transition: opacity 600ms cubic-bezier(0.2, 0.8, 0.2, 1);
pointer-events: none;
}
.fq__slide.is-active {
opacity: 1;
pointer-events: auto;
}
.fq__quote {
margin: 0 0 8px;
font-weight: 700;
font-size: clamp(12px, 3.6vw, 14px);
line-height: 1.5;
font-family: inherit;
color: #17141a;
}
.fq__who {
font-weight: 600;
font-size: 11px;
line-height: 1.4;
font-family: inherit;
color: rgba(23, 20, 26, 0.72);
}
.fq__name {
color: #17141a;
}
.fq__dots {
display: flex;
justify-content: center;
gap: 8px;
margin-top: 12px;
}
.fq__dot {
width: 8px;
height: 8px;
border-radius: 50%;
border: 0;
padding: 0;
background: rgba(23, 20, 26, 0.2);
cursor: pointer;
transition: background 600ms cubic-bezier(0.2, 0.8, 0.2, 1), transform 600ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.fq__dot.is-active {
background: #17141a;
transform: scale(1.3);
}
@media (max-width: 340px), (max-height: 220px) {
.fq {
padding: 16px 12px 12px;
}
.fq__mark {
font-size: 28px;
}
.fq__stage {
min-height: 70px;
}
.fq__quote {
font-size: 11px;
}
}
@media (prefers-reduced-motion: reduce) {
.fq__mark {
animation: none !important;
}
.fq__slide, .fq__dot {
transition: none;
}
}// 실제 동작: 점을 클릭하면 그 인용문으로 실제로 크로스페이드 전환된다.
var root = document.querySelector('.fq');
var dots = root.querySelectorAll('.fq__dot');
var slides = root.querySelectorAll('.fq__slide');
dots.forEach(function (dot) {
dot.addEventListener('click', function () {
root.classList.remove('is-demo');
var i = dot.getAttribute('data-i');
dots.forEach(function (d) { d.classList.remove('is-active'); d.setAttribute('aria-selected', 'false'); });
slides.forEach(function (s) { s.classList.remove('is-active'); });
dot.classList.add('is-active'); dot.setAttribute('aria-selected', 'true');
root.querySelector('.fq__slide[data-i="' + i + '"]').classList.add('is-active');
});
});import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./PressFounderQuote.module.scss";
export type PressFounderQuoteProps = { duration?: number; easing?: string; color?: string };
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const QUOTES = [
{ quote: "\"작은 팀도 대형 언론과 같은 속도로 소식을 전할 수 있어야 한다고 믿습니다.\"", name: "이도현", role: "공동창업자 & CEO" },
{ quote: "\"프레스룸은 홍보가 아니라 신뢰를 확인시켜주는 자리입니다.\"", name: "박서연", role: "공동창업자 & CTO" },
];
export default function PressFounderQuote({ duration = 600, easing = "cubic-bezier(.2,.8,.2,1)", color = "#17141a" }: PressFounderQuoteProps) {
const [active, setActive] = useState(0);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<section className={styles.fq} style={vars}>
<span className={styles.mark} aria-hidden="true">"</span>
<div className={styles.stage}>
{QUOTES.map((q, i) => (
<figure key={q.name} className={i === active ? `${styles.slide} ${styles.active}` : styles.slide}>
<blockquote className={styles.quote}>{q.quote}</blockquote>
<figcaption className={styles.who}><span className={styles.name}>{q.name}</span> · {q.role}</figcaption>
</figure>
))}
</div>
<div className={styles.dots} role="group" aria-label="인용문 선택">
{QUOTES.map((q, i) => (
<button
key={q.name}
type="button"
className={i === active ? `${styles.dot} ${styles.active}` : styles.dot}
aria-selected={i === active}
aria-label={`${i + 1}번째 인용`}
onClick={() => setActive(i)}
/>
))}
</div>
</section>
);
}07 · 창업자 인용 — 측정 누적면적 0.366% · 움직인 프레임 17/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
따옴표 장식이 흔들린다
점을 클릭하면 그 인용문으로 실제로 크로스페이드 전환되고, 큰 따옴표 장식이 은은하게 흔들립니다.
dots.forEach(function (dot) {
dot.addEventListener('click', function () {
root.classList.remove('is-demo');
var i = dot.getAttribute('data-i');
dots.forEach(function (d) { d.classList.remove('is-active'); d.setAttribute('aria-selected', 'false'); });
slides.forEach(function (s) { s.classList.remove('is-active'); });
dot.classList.add('is-active'); dot.setAttribute('aria-selected', 'true');
root.querySelector('.fq__slide[data-i="' + i + '"]').classList.add('is-active');
});
});
08연혁 타임라인
클릭한 지점까지 채워진다
가로선 위 연혁 네 지점(창립·시리즈 A·해외 진출·100만 사용자)을 클릭하면 그 지점까지 진행바가 scaleX로 실제로 채워집니다.

<main class="stage">
<section class="mt is-demo" data-active="0">
<p class="mt__desc" role="status">2019년, 세 명이 작은 사무실에서 첫 제품을 출시했습니다.</p>
<div class="mt__track">
<span class="mt__fill" aria-hidden="true"></span>
<ol class="mt__list">
<li><button class="mt__stop" type="button" data-i="0" aria-current="step">
<span class="mt__dot" aria-hidden="true"></span><span class="mt__year">2019</span><span class="mt__label">창립</span>
</button></li>
<li><button class="mt__stop" type="button" data-i="1">
<span class="mt__dot" aria-hidden="true"></span><span class="mt__year">2021</span><span class="mt__label">시리즈 A</span>
</button></li>
<li><button class="mt__stop" type="button" data-i="2">
<span class="mt__dot" aria-hidden="true"></span><span class="mt__year">2023</span><span class="mt__label">해외 진출</span>
</button></li>
<li><button class="mt__stop" type="button" data-i="3">
<span class="mt__dot" aria-hidden="true"></span><span class="mt__year">2026</span><span class="mt__label">100만 사용자</span>
</button></li>
</ol>
</div>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-base; // 진행바·설명 전환
$easing: $ease-spring;
$color: $subject-blue; // 진행바·현재 지점 색
@include stage($stage-paper);
.mt { width: min(420px, 95vw); }
.mt__desc {
min-height: 32px;
margin: 0 0 $sp-3;
font-weight: 600; font-size: clamp(11px, 3.2vw, 12px); line-height: 1.5; font-family: inherit;
color: $subject-ink;
}
.mt__track { position: relative; }
.mt__track::before {
content: "";
position: absolute; left: 0; right: 0; top: 4px;
height: 3px;
border-radius: $r-xs;
background: rgba(23, 20, 26, .12);
}
.mt__fill {
position: absolute; left: 0; right: 0; top: 4px;
height: 3px;
border-radius: $r-xs;
background: $color;
transform: scaleX(.25);
transform-origin: left; // 규칙8: 진행바 left 채움
transition: transform $duration $easing;
}
.mt__list { position: relative; list-style: none; margin: 0; padding: $sp-3 0 0; display: flex; justify-content: space-between; }
.mt__stop {
display: flex; flex-direction: column; align-items: center; gap: $sp-1;
border: 0; padding: 0; background: none; cursor: pointer;
}
.mt__dot {
width: 10px; height: 10px;
border-radius: 50%;
background: rgba(23, 20, 26, .3);
transition: background $duration $easing, transform $duration $easing, box-shadow $duration $easing;
}
.mt__stop[aria-current="step"] .mt__dot {
background: $color;
transform: scale(1.3);
box-shadow: 0 0 0 4px rgba(47, 109, 246, .18);
}
.mt__year { font-weight: 800; font-size: 10px; line-height: 1; font-family: inherit; color: $subject-ink; }
.mt__label { font-weight: 600; font-size: 9px; line-height: 1.3; font-family: inherit; color: rgba($subject-ink, .72); }
// ── 데모 루프: 현재 지점 점이 계속 펄스한다(항상 재생) ──────────────
.mt.is-demo .mt__stop[aria-current="step"] .mt__dot { animation: mt-pulse 2s $easing infinite; }
@keyframes mt-pulse {
0%, 100% { box-shadow: 0 0 0 4px rgba(47, 109, 246, .18); }
50% { box-shadow: 0 0 0 8px rgba(47, 109, 246, .08); }
}
// ── 반응형(BD9) ──────────────────────────────────────────
@media (max-width: 340px), (max-height: 220px) {
.mt__desc { min-height: 0; margin-bottom: $sp-2; font-size: 10px; }
.mt__label { display: none; }
}
// ── 접근성 ──────────────────────────────────────────
@media (prefers-reduced-motion: reduce) {
.mt__fill, .mt__dot { transition: none; }
.mt.is-demo .mt__stop[aria-current="step"] .mt__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: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.mt {
width: min(420px, 95vw);
}
.mt__desc {
min-height: 32px;
margin: 0 0 12px;
font-weight: 600;
font-size: clamp(11px, 3.2vw, 12px);
line-height: 1.5;
font-family: inherit;
color: #17141a;
}
.mt__track {
position: relative;
}
.mt__track::before {
content: "";
position: absolute;
left: 0;
right: 0;
top: 4px;
height: 3px;
border-radius: 4px;
background: rgba(23, 20, 26, 0.12);
}
.mt__fill {
position: absolute;
left: 0;
right: 0;
top: 4px;
height: 3px;
border-radius: 4px;
background: #2f6df6;
transform: scaleX(0.25);
transform-origin: left;
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.mt__list {
position: relative;
list-style: none;
margin: 0;
padding: 12px 0 0;
display: flex;
justify-content: space-between;
}
.mt__stop {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
border: 0;
padding: 0;
background: none;
cursor: pointer;
}
.mt__dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: rgba(23, 20, 26, 0.3);
transition: background 300ms cubic-bezier(0.2, 0.8, 0.2, 1), transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1), box-shadow 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.mt__stop[aria-current=step] .mt__dot {
background: #2f6df6;
transform: scale(1.3);
box-shadow: 0 0 0 4px rgba(47, 109, 246, 0.18);
}
.mt__year {
font-weight: 800;
font-size: 10px;
line-height: 1;
font-family: inherit;
color: #17141a;
}
.mt__label {
font-weight: 600;
font-size: 9px;
line-height: 1.3;
font-family: inherit;
color: rgba(23, 20, 26, 0.72);
}
.mt.is-demo .mt__stop[aria-current=step] .mt__dot {
animation: mt-pulse 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes mt-pulse {
0%, 100% {
box-shadow: 0 0 0 4px rgba(47, 109, 246, 0.18);
}
50% {
box-shadow: 0 0 0 8px rgba(47, 109, 246, 0.08);
}
}
@media (max-width: 340px), (max-height: 220px) {
.mt__desc {
min-height: 0;
margin-bottom: 8px;
font-size: 10px;
}
.mt__label {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.mt__fill, .mt__dot {
transition: none;
}
.mt.is-demo .mt__stop[aria-current=step] .mt__dot {
animation: none !important;
}
}// 실제 동작: 연혁을 클릭하면 그 지점까지 진행바가 scaleX 로 실제로 채워지고 설명 문단이 바뀐다.
var milestoneNotes = [
'2019년, 세 명이 작은 사무실에서 첫 제품을 출시했습니다.',
'2021년, 시리즈 A 투자를 유치하며 팀을 열 배로 키웠습니다.',
'2023년, 일본·미국 지사를 열고 해외 진출을 시작했습니다.',
'2026년, 누적 가입자 100만 명을 넘겼습니다.'
];
var root = document.querySelector('.mt');
var stops = root.querySelectorAll('.mt__stop');
var desc = root.querySelector('.mt__desc');
stops.forEach(function (btn) {
btn.addEventListener('click', function () {
root.classList.remove('is-demo');
var i = Number(btn.getAttribute('data-i'));
root.setAttribute('data-active', String(i));
root.querySelector('.mt__fill').style.transform = 'scaleX(' + ((i + 1) / 4) + ')';
stops.forEach(function (s) { s.removeAttribute('aria-current'); });
btn.setAttribute('aria-current', 'step');
desc.textContent = milestoneNotes[i];
});
});import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./PressMilestoneTimeline.module.scss";
export type PressMilestoneTimelineProps = { duration?: number; easing?: string; color?: string };
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const STOPS = [
{ year: "2019", label: "창립", desc: "2019년, 세 명이 작은 사무실에서 첫 제품을 출시했습니다." },
{ year: "2021", label: "시리즈 A", desc: "2021년, 시리즈 A 투자를 유치하며 팀을 열 배로 키웠습니다." },
{ year: "2023", label: "해외 진출", desc: "2023년, 일본·미국 지사를 열고 해외 진출을 시작했습니다." },
{ year: "2026", label: "100만 사용자", desc: "2026년, 누적 가입자 100만 명을 넘겼습니다." },
];
export default function PressMilestoneTimeline({ duration = 300, easing = "cubic-bezier(.2,.8,.2,1)", color = "#2f6df6" }: PressMilestoneTimelineProps) {
const [active, setActive] = useState(0);
const [interacted, setInteracted] = useState(false);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<section className={interacted ? styles.mt : `${styles.mt} ${styles.demo}`} style={vars}>
<p className={styles.desc} role="status">{STOPS[active].desc}</p>
<div className={styles.track}>
<span className={styles.fill} style={{ transform: `scaleX(${(active + 1) / STOPS.length})` }} aria-hidden="true" />
<ol className={styles.list}>
{STOPS.map((s, i) => (
<li key={s.year}>
<button
type="button"
className={styles.stop}
aria-current={i === active ? "step" : undefined}
onClick={() => { setInteracted(true); setActive(i); }}
>
<span className={styles.dot} aria-hidden="true" />
<span className={styles.year}>{s.year}</span>
<span className={styles.label}>{s.label}</span>
</button>
</li>
))}
</ol>
</div>
</section>
);
}08 · 연혁 타임라인 — 측정 누적면적 0.438% · 움직인 프레임 7/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
설명 문단이 함께 바뀐다
위 설명 문단도 그 시점 내용으로 바뀝니다.
stops.forEach(function (btn) {
btn.addEventListener('click', function () {
root.classList.remove('is-demo');
var i = Number(btn.getAttribute('data-i'));
root.setAttribute('data-active', String(i));
root.querySelector('.mt__fill').style.transform = 'scaleX(' + ((i + 1) / 4) + ')';
stops.forEach(function (s) { s.removeAttribute('aria-current'); });
btn.setAttribute('aria-current', 'step');
desc.textContent = milestoneNotes[i];
});
});
09프레스 푸터
형식을 진짜로 검사한다
이메일을 넣고 구독 버튼을 누르면 실제로 형식을 검사합니다. 통과하면 체크가 그려지고 안내 문구가 뜨며, 형식이 틀리면 입력칸이 좌우로 짧게 흔들립니다.

<main class="stage">
<footer class="pf">
<div class="pf__top">
<p class="pf__heading">새 보도자료를 메일로 받아보세요</p>
<form class="pf__form" novalidate>
<input class="pf__input" type="email" placeholder="[email protected]" aria-label="이메일">
<button class="pf__submit" type="submit">
<span class="pf__submit-label">구독</span>
<svg class="pf__check" viewBox="0 0 20 20" aria-hidden="true"><polyline points="4,10 8,14 16,5"/></svg>
</button>
</form>
<p class="pf__status" role="status" aria-live="polite"></p>
</div>
<div class="pf__cols">
<div class="pf__col"><p class="pf__col-title">프레스</p><a href="#">보도자료</a><a href="#">미디어 키트</a></div>
<div class="pf__col"><p class="pf__col-title">회사</p><a href="#">소개</a><a href="#">채용</a></div>
<div class="pf__col"><p class="pf__col-title">팔로우</p><a href="#">X</a><a href="#">LinkedIn</a></div>
</div>
</footer>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-base; // 체크·버튼 전환
$easing: $ease-pop;
$color: $subject-blue; // 버튼·체크 색
@include stage($stage-ink);
.pf { width: min(440px, 96vw); }
.pf__top { text-align: center; margin-bottom: $sp-4; }
.pf__heading {
margin: 0 0 $sp-2;
font-weight: 700; font-size: clamp(11px, 3.2vw, 12px); line-height: 1.4; font-family: inherit;
color: $subject-cream;
}
.pf__form { display: flex; gap: $sp-2; justify-content: center; flex-wrap: wrap; }
.pf__input {
flex: 1 1 160px;
max-width: 200px;
padding: $sp-2 $sp-3;
border-radius: $r-control;
border: 1px solid rgba(255, 247, 230, .24);
background: rgba(255, 247, 230, .06);
color: $subject-cream;
font-weight: 500; font-size: 11px; line-height: 1; font-family: inherit;
}
.pf__input::placeholder { color: rgba(255, 247, 230, .4); }
.pf__input.is-shake { animation: pf-shake $dur-quick $ease-out; }
@keyframes pf-shake {
0%, 100% { transform: translateX(0); }
25% { transform: translateX(-5px); }
75% { transform: translateX(5px); }
}
.pf__submit {
position: relative;
display: inline-flex; align-items: center; gap: $sp-1;
padding: $sp-2 $sp-4;
border-radius: $r-control;
border: 0;
background: $subject-blue-deep;
color: $subject-cream;
font-weight: 700; font-size: 11px; line-height: 1; font-family: inherit;
cursor: pointer;
transition: transform $duration $easing;
animation: pf-glow 2s ease-in-out infinite;
}
@keyframes pf-glow {
0%, 100% { box-shadow: 0 0 0 0 rgba(47, 109, 246, .35); }
50% { box-shadow: 0 0 0 6px rgba(47, 109, 246, 0); }
}
.pf__check {
width: 12px; height: 12px;
fill: none; stroke: currentColor; stroke-width: 2.5;
stroke-linecap: round; stroke-linejoin: round;
stroke-dasharray: 20; stroke-dashoffset: 20;
opacity: 0;
transition: stroke-dashoffset $duration $easing, opacity $duration $easing;
}
.pf__form.is-done .pf__submit { transform: scale(1.04); }
.pf__form.is-done .pf__check { stroke-dashoffset: 0; opacity: 1; }
.pf__form.is-done .pf__submit-label { display: none; }
.pf__status { min-height: 14px; margin: $sp-2 0 0; font-weight: 600; font-size: 10px; line-height: 1.4; font-family: inherit; color: $subject-cream; }
.pf__cols { display: flex; justify-content: center; gap: $sp-6; padding-top: $sp-3; border-top: 1px solid rgba(255, 247, 230, .12); }
.pf__col { display: flex; flex-direction: column; gap: $sp-1; }
.pf__col-title { margin: 0 0 $sp-1; font-weight: 700; font-size: 9px; line-height: 1; font-family: inherit; letter-spacing: .08em; color: rgba($subject-cream, .72); }
.pf__col a { font-weight: 500; font-size: 10px; line-height: 1.6; font-family: inherit; color: rgba(255, 247, 230, .72); text-decoration: none; }
// ── 반응형(BD9) ──────────────────────────────────────────
@media (max-width: 340px), (max-height: 220px) {
.pf__top { margin-bottom: $sp-2; }
.pf__cols { gap: $sp-3; padding-top: $sp-2; }
.pf__col a { font-size: 9px; }
}
// ── 접근성 ──────────────────────────────────────────
@media (prefers-reduced-motion: reduce) {
.pf__submit { animation: none !important; }
.pf__input, .pf__check { transition: none; 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);
}
.pf {
width: min(440px, 96vw);
}
.pf__top {
text-align: center;
margin-bottom: 16px;
}
.pf__heading {
margin: 0 0 8px;
font-weight: 700;
font-size: clamp(11px, 3.2vw, 12px);
line-height: 1.4;
font-family: inherit;
color: #fff7e6;
}
.pf__form {
display: flex;
gap: 8px;
justify-content: center;
flex-wrap: wrap;
}
.pf__input {
flex: 1 1 160px;
max-width: 200px;
padding: 8px 12px;
border-radius: 12px;
border: 1px solid rgba(255, 247, 230, 0.24);
background: rgba(255, 247, 230, 0.06);
color: #fff7e6;
font-weight: 500;
font-size: 11px;
line-height: 1;
font-family: inherit;
}
.pf__input::placeholder {
color: rgba(255, 247, 230, 0.4);
}
.pf__input.is-shake {
animation: pf-shake 220ms ease-out;
}
@keyframes pf-shake {
0%, 100% {
transform: translateX(0);
}
25% {
transform: translateX(-5px);
}
75% {
transform: translateX(5px);
}
}
.pf__submit {
position: relative;
display: inline-flex;
align-items: center;
gap: 4px;
padding: 8px 16px;
border-radius: 12px;
border: 0;
background: #0a4fe8;
color: #fff7e6;
font-weight: 700;
font-size: 11px;
line-height: 1;
font-family: inherit;
cursor: pointer;
transition: transform 300ms cubic-bezier(0.2, 1.4, 0.4, 1);
animation: pf-glow 2s ease-in-out infinite;
}
@keyframes pf-glow {
0%, 100% {
box-shadow: 0 0 0 0 rgba(47, 109, 246, 0.35);
}
50% {
box-shadow: 0 0 0 6px rgba(47, 109, 246, 0);
}
}
.pf__check {
width: 12px;
height: 12px;
fill: none;
stroke: currentColor;
stroke-width: 2.5;
stroke-linecap: round;
stroke-linejoin: round;
stroke-dasharray: 20;
stroke-dashoffset: 20;
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);
}
.pf__form.is-done .pf__submit {
transform: scale(1.04);
}
.pf__form.is-done .pf__check {
stroke-dashoffset: 0;
opacity: 1;
}
.pf__form.is-done .pf__submit-label {
display: none;
}
.pf__status {
min-height: 14px;
margin: 8px 0 0;
font-weight: 600;
font-size: 10px;
line-height: 1.4;
font-family: inherit;
color: #fff7e6;
}
.pf__cols {
display: flex;
justify-content: center;
gap: 24px;
padding-top: 12px;
border-top: 1px solid rgba(255, 247, 230, 0.12);
}
.pf__col {
display: flex;
flex-direction: column;
gap: 4px;
}
.pf__col-title {
margin: 0 0 4px;
font-weight: 700;
font-size: 9px;
line-height: 1;
font-family: inherit;
letter-spacing: 0.08em;
color: rgba(255, 247, 230, 0.72);
}
.pf__col a {
font-weight: 500;
font-size: 10px;
line-height: 1.6;
font-family: inherit;
color: rgba(255, 247, 230, 0.72);
text-decoration: none;
}
@media (max-width: 340px), (max-height: 220px) {
.pf__top {
margin-bottom: 8px;
}
.pf__cols {
gap: 12px;
padding-top: 8px;
}
.pf__col a {
font-size: 9px;
}
}
@media (prefers-reduced-motion: reduce) {
.pf__submit {
animation: none !important;
}
.pf__input, .pf__check {
transition: none;
animation: none !important;
}
}// 실제 동작: 제출 시 실제로 이메일 형식을 검사한다. 통과하면 체크가 그려지고, 틀리면 입력칸이 흔들린다.
var form = document.querySelector('.pf__form');
var input = document.querySelector('.pf__input');
var status = document.querySelector('.pf__status');
form.addEventListener('submit', function (e) {
e.preventDefault();
var ok = /^\S+@\S+\.\S+$/.test(input.value);
if (ok) {
form.classList.add('is-done');
status.textContent = '구독 완료 — 확인 메일을 보냈습니다.';
} else {
input.classList.remove('is-shake');
void input.offsetWidth;
input.classList.add('is-shake');
status.textContent = '올바른 이메일을 입력해 주세요.';
}
});import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./PressFooter.module.scss";
export type PressFooterProps = { duration?: number; easing?: string; color?: string };
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function PressFooter({ duration = 300, easing = "cubic-bezier(.2,1.4,.4,1)", color = "#0a4fe8" }: PressFooterProps) {
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 (
<footer className={styles.pf} style={vars}>
<div className={styles.top}>
<p className={styles.heading}>새 보도자료를 메일로 받아보세요</p>
<form className={styles.form} onSubmit={onSubmit}>
<input
className={status === "error" ? `${styles.input} ${styles.shake}` : styles.input}
type="email"
placeholder="[email protected]"
aria-label="이메일"
value={email}
onChange={(e: any) => setEmail(e.target.value)}
/>
<button type="submit" className={styles.submit}>
{status !== "done" && <span className={styles.submitLabel}>구독</span>}
<svg className={styles.check} viewBox="0 0 20 20" aria-hidden="true" style={{ opacity: status === "done" ? 1 : 0 }}>
<polyline points="4,10 8,14 16,5" />
</svg>
</button>
</form>
<p className={styles.status} role="status" aria-live="polite">
{status === "done" ? "구독 완료 — 확인 메일을 보냈습니다." : status === "error" ? "올바른 이메일을 입력해 주세요." : ""}
</p>
</div>
<div className={styles.cols}>
<div className={styles.col}><p className={styles.colTitle}>프레스</p><a href="#">보도자료</a><a href="#">미디어 키트</a></div>
<div className={styles.col}><p className={styles.colTitle}>회사</p><a href="#">소개</a><a href="#">채용</a></div>
<div className={styles.col}><p className={styles.colTitle}>팔로우</p><a href="#">X</a><a href="#">LinkedIn</a></div>
</div>
</footer>
);
}09 · 프레스 푸터 — 측정 누적면적 0.578% · 움직인 프레임 15/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');
status.textContent = '구독 완료 — 확인 메일을 보냈습니다.';
} else {
input.classList.remove('is-shake');
void input.offsetWidth;
input.classList.add('is-shake');
status.textContent = '올바른 이메일을 입력해 주세요.';
}
});
어디서 깨지는가 — 함정
불투명 배경이 원을 가렸다
아홉 개를 만드는 동안 실제로 두 번 걸렸습니다. 첫 번째는 미디어 문의 카드(06)였습니다. 처음엔 카드 배경을 불투명한 크림색으로 칠했는데, 배경에 넣은 드리프트 원이 z-index:-1이라 그 불투명한 배경 뒤에 완전히 가려져 버렸습니다. 화면엔 아무 문제가 없어 보였지만 실제로 움직이는 픽셀이 거의 없어 애니메이션이라 부르기 민망한 수준이었습니다. 카드 배경을 없애고 원이 무대 색 위에서 바로 보이게 하자 해결됐습니다.
box-sizing 이 빠지면
두 번째는 보도자료 목록(02)과 미디어 키트(03)의 반응형이었습니다. 두 항목 모두 max-height로 행을 접었다 펴는데, box-sizing을 지정하지 않으면 padding이 지정한 높이 위에 그대로 더해져 실제 렌더 높이가 더 커집니다. 320px 폭 화면에서 이 차이가 쌓이며 조용히 세로 스크롤이 생겼습니다. 두 항목 모두 box-sizing: border-box를 넣고 좁은 화면용 패딩을 줄이자 통과했습니다.
마퀴 간격은 margin-right
마퀴로 도는 언론 보도 로고 바(04)에도 흔한 함정이 있습니다. 트랙 항목 사이 간격을 gap으로 주면 두 벌(2N개) 사이 이음새가 gap ÷ 2만큼 모자라 한 바퀴 돌 때마다 살짝 튑니다. 숫자가 담긴 자료를 그림으로만 올려 검색에 잡히지 않는 이 실수는 대시보드 차트 인터랙션에서 데이터를 실제로 다룰 때 다시 만납니다. 이 템플릿은 처음부터 margin-right로 간격을 줘서 이 문제를 피했고, 7a6mw6kt으로 열어 보는 zip 안의 코드도 그 방식 그대로입니다. 위 두 함정을 포함해 아홉 개가 표대로 정확히 동작하는 최신 상태입니다.
접근성
동작 줄이기에서도 상태는 남는다
아홉 개 전부 prefers-reduced-motion: reduce에서 장식 애니메이션을 끄되 상태 자체는 남깁니다 — 연혁 진행바는 채워진 채로, 필터링된 목록은 접힌 채로 고정되고 숫자 하이라이트의 카운트업은 정보이므로 그대로 유지합니다.
네이티브 컨트롤과 상태 알림
미디어 키트(03)의 체크박스는 네이티브 input[type="checkbox"]라 Tab·Space로 그대로 조작되고, 미디어 문의 카드(06)와 프레스 푸터(09)의 결과 문구는 role="status"(aria-live)라 스크린 리더가 복사·제출 결과를 바로 읽습니다. 연혁 타임라인(08)은 aria-current="step"으로 지금 보는 지점을 알리고, 창업자 인용(07)의 점 버튼은 role="group"과 aria-selected로 상태를 전달합니다.
클립보드와 폴백
클립보드 복사(06)는 MDN Clipboard API 명세를 따르고, 지원하지 않는 환경에서는 textarea 폴백으로 넘어갑니다. 이메일·대시보드 같은 다른 정적 템플릿은 템플릿 카테고리 목록에서 더 볼 수 있고, 이 블로그를 운영하는 사람이 누구인지는 소개 페이지에 적어 뒀습니다.
FAQ
프레스룸 디자인에 실제 로고나 사진을 써도 되나요?
네, 애초에 그러라고 만들었습니다. 아바타 원·언론사 로고 블록을 실제 파일로 바꿔 넣어도 크기·정렬은 그대로 유지됩니다. 도형 자체는 demo-tokens.scss의 먹·파랑·크림 값을 쓰고, 무대 배경(옐로·오렌지 등)은 완전히 다른 토큰이라 둘을 섞어 쓸 일이 없습니다.
React 버전은 CSS 버전과 뭐가 다른가요?
동작은 같습니다. 필터 상태·체크박스·활성 인용문 같은 값은 useState로 관리하고, 변수 3개(duration·easing·color)는 props로 받아 CSS 변수(--duration --easing --color)로 넘깁니다. 숫자 하이라이트(05)의 카운트업은 useEffect 안에서 같은 requestAnimationFrame 로직을 그대로 씁니다.
미디어 키트 다운로드는 실제로 파일을 내려받나요, 아니면 장식인가요?
체크박스 선택과 버튼 활성화, 완료 표시는 전부 실제 상태 변화입니다. 다만 데모 자체엔 연결된 실제 파일이 없어 클릭한다고 진짜 다운로드가 시작되지는 않습니다. 실제로 쓸 때는 각 파일 행에 실제 href만 연결하면 나머지 체크·카운트 로직은 그대로 재사용할 수 있습니다. 기자 대신 고객을 설득하는 근거가 필요하면 믿음 가는 랜딩 섹션 템플릿이 그 묶음입니다.









