9 Media Page Templates — Copy-Paste
A media page template bundles the sections a press room needs — release feed, downloadable assets, spokesperson contact — and one of the nine below copies a
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
Auto-plays · click a tile to jump to its section · all nine in one zip
- 01 Press hero
- 02 Press release list
- 03 Media kit download
- 04 Press mentions bar
- 05 Stat counter
- 06 Media contact card
- 07 Founder quote
- 08 Milestone timeline
- 09 Press footer
The nine aren't sorted by popularity — they follow the order a reporter or investor actually scrolls a press page. A hero states who you are, a release list proves you're active, a media kit hands over assets a journalist can use without asking, a mentions bar shows where you've already landed, a stat block backs that up with numbers, a contact card removes the excuse not to reach out, a founder quote adds a human voice, a timeline shows the climb that got here, and a footer keeps the relationship going after the visit ends. Every avatar circle, logo block, and quote mark below is a shape drawn from this site's own $subject-ink, $subject-blue, and $subject-cream tokens — none of it is a real photo or a real press logo, and swapping in either later doesn't touch the layout or the JavaScript underneath. When the announcement is tied to a date, the event landing templates take the slot in front, and a place to lay out work instead of coverage is the project showcase templates.
01Press hero
A press page template hero
Three blurred circles drift behind the headline on a continuous translate-and-scale loop that never stops, while a rotated-square wordmark placeholder, headline, subhead, and two CTAs hold their position in front.

<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">For press & partners<br>Latest news</h1>
<p class="ph__sub">Press releases, media kit, and contacts in one place.</p>
<div class="ph__row">
<a class="ph__cta" href="#">View releases</a>
<button class="ph__sub-btn" type="button" aria-pressed="false">
<span class="ph__sub-ic" aria-hidden="true"></span>
Subscribe
</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;
}
}// 실제 동작: 클릭마다 Subscribe 상태가 실제로 바뀐다(아이콘 채움 + 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 · Press hero — Measured area 30.411% · moving frames 18/23Original by GODRICH · SCSS and React ship in the zip · See the password
A subscribe button that really toggles
The right-hand "subscribe to releases" button actually flips aria-pressed and fills its bookmark icon on every real click, which is what a company press room or a media hub's top section needs.
.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);
}
02Press release list
A company news page filter that really folds rows
Clicking a category chip (all, product, partnership, award) actually folds every row that doesn't match with a max-height and opacity transition rather than just dimming it, and the filter is exclusive — only one chip can read aria-pressed="true" at a time.

<main class="stage">
<section class="rl is-demo">
<div class="rl__filters" role="group" aria-label="Category">
<button class="rl__chip" type="button" data-cat="all" aria-pressed="true">All</button>
<button class="rl__chip" type="button" data-cat="product" aria-pressed="false">Product</button>
<button class="rl__chip" type="button" data-cat="partner" aria-pressed="false">Partnership</button>
<button class="rl__chip" type="button" data-cat="award" aria-pressed="false">Awards</button>
</div>
<ul class="rl__list">
<li class="rl__row" data-cat="product"><span class="rl__date">08.20</span><span class="rl__title">New workspace features</span><span class="rl__tag">Product</span></li>
<li class="rl__row" data-cat="partner"><span class="rl__date">07.02</span><span class="rl__title">Distribution deal with Company B</span><span class="rl__tag">Partnership</span></li>
<li class="rl__row" data-cat="award"><span class="rl__date">06.15</span><span class="rl__title">Wins Startup of the Year</span><span class="rl__tag">Awards</span></li>
<li class="rl__row" data-cat="product"><span class="rl__date">05.30</span><span class="rl__title">Version 3.0 released</span><span class="rl__tag">Product</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;
}
}// 실제 동작: Category 칩을 클릭하면 그 분류가 아닌 행이 실제로 접히고 칩 상태가 배타적으로 바뀐다.
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 · Press release list — Measured area 0.271% · moving frames 16/23Original by GODRICH · SCSS and React ship in the zip · See the password
Only one chip at a time
It's built for a press room's release archive or a newsroom index above the fold.
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);
});
});
});
03Media kit download
A press kit template with real checkboxes
Checking any of the three files — logo pack, brand guide, product screenshots — actually enables the download button and appends the count to its label; clicking it draws a checkmark onto each selected row with stroke-dashoffset rather than just swapping an icon.

<main class="stage">
<section class="mk">
<h2 class="mk__title">Media kit</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">Logo pack (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">Brand guide (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">Product shots (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>
Download selected
</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;
}
}// 실제 동작: 체크한 Downloaded 수만큼 버튼 라벨이 바뀌고, 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 ? ' Download selected (' + checkedFiles.length + ')' : ' Download selected';
}
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 = 'Downloaded ' + checkedFiles.length + ' files.';
});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 · Media kit download — Measured area 0.756% · moving frames 22/23Original by GODRICH · SCSS and React ship in the zip · See the password
What the click draws
It suits a media kit section or a brand resource page where a journalist needs to grab more than one asset at once.
.mk::before {
content: "";
position: absolute; left: 0; right: 0; top: 0;
height: 3px;
background: $color;
animation: mk-scan 2s $easing infinite;
}
.mk__done-ic {
stroke: $color;
stroke-width: 2.5;
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__btn:disabled { opacity: .4; cursor: not-allowed; }
04Press mentions bar
A track with no visible seam
Eight placeholder outlet marks — flat shapes, not real logos — sit in a track duplicated end to end so a translateX(-50%) loop scrolls with no visible seam, and hovering the strip or tapping the pause button actually freezes it in place.

<main class="stage">
<section class="mb" aria-label="Press coverage">
<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">Pause the feed</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 · Press mentions bar — Measured area 8.057% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
Freezing it in place
This is the standard spot for an "as seen in" row or a partner logo bar under a hero.
// the track skips gap entirely — each logo gets margin-right instead
.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; }
.mb__logo { flex: 0 0 auto; width: 64px; height: 32px; margin-right: $sp-6; }
@keyframes mb-scroll {
from { transform: translateX(0); }
to { transform: translateX(-50%); }
}
05Stat counter
Climbing from zero to target
Three cards' big numbers climb from zero to their real targets — 128, 42, 3,200 — the instant the page loads, run through an eased curve rather than a linear tick, and land exactly on the target with no overshoot.

<main class="stage">
<section class="sc">
<div class="sc__card">
<p class="sc__num" data-target="128">0</p>
<p class="sc__label">Press mentions</p>
</div>
<div class="sc__card">
<p class="sc__num" data-target="42">0</p>
<p class="sc__label">Partner outlets</p>
</div>
<div class="sc__card">
<p class="sc__num" data-target="3200">0</p>
<p class="sc__label">Total downloads</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 · Stat counter — Measured area 2.675% · moving frames 21/23Original by GODRICH · SCSS and React ship in the zip · See the password
Why the digits never jitter
The digits carry font-variant-numeric: tabular-nums so they don't jitter in width while counting, and this fits an "our numbers" block on an about page or a press page's credibility strip.
.sc__card::before {
content: "";
position: absolute; left: 0; right: 0; top: 0;
height: 3px;
background: $color;
animation: sc-pulse 2s $easing infinite;
}
.sc__card:nth-child(2)::before { animation-delay: 220ms; }
.sc__num {
font-weight: 800; font-size: clamp(20px, 7vw, 28px); line-height: 1; font-family: inherit;
color: $subject-cream;
font-variant-numeric: tabular-nums;
}
06Media contact card
Writing to the clipboard for real
Clicking the email button actually writes the address to the clipboard through navigator.clipboard.writeText(), then draws a checkmark onto the button with stroke-dashoffset before reverting the label 1.6 seconds later.

<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">Kim Press</p>
<p class="mc__role">PR & Media Lead</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 = 'Email address copied.';
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 · Media contact card — Measured area 3.774% · moving frames 6/23Original by GODRICH · SCSS and React ship in the zip · See the password
Reverting after 1.6 seconds
It's built for the "reach our PR team" card that sits at the bottom of a press room or an interview-request section.
.mc__orb--1 { background: radial-gradient(circle, $subject-blue, transparent 68%); animation: mc-drift 8s ease-in-out infinite; }
.mc__orb--2 { background: radial-gradient(circle, $subject-ink, transparent 68%); animation: mc-drift 7s ease-in-out infinite reverse; }
.mc__avatar { background: $subject-ink; border-radius: 50%; }
.mc__copy-ic {
stroke: $color;
stroke-dasharray: 20;
stroke-dashoffset: 20;
opacity: 0;
transition: stroke-dashoffset $duration $easing, opacity $duration $easing;
}
.mc__copy.is-copied .mc__copy-ic { stroke-dashoffset: 0; opacity: 1; }
07Founder quote
Two quotes behind two dots
Two founder quotes trade places behind a pair of dots — clicking one runs a real opacity cross-fade between two absolutely-positioned slides, while a large quotation mark keeps wobbling with rotate and scale in the background the whole time.

<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">"We believe even a small team can share news as fast as major media."</blockquote>
<figcaption class="fq__who"><span class="fq__name">Lee Dohyun</span> · Co-founder & CEO</figcaption>
</figure>
<figure class="fq__slide" data-i="1">
<blockquote class="fq__quote">"A press room isn't for promotion—it's where trust is confirmed."</blockquote>
<figcaption class="fq__who"><span class="fq__name">Park Seoyeon</span> · Co-founder & CTO</figcaption>
</figure>
</div>
<div class="fq__dots" role="group" aria-label="Select quote">
<button class="fq__dot is-active" type="button" data-i="0" aria-selected="true" aria-label="First quote"></button>
<button class="fq__dot" type="button" data-i="1" aria-selected="false" aria-label="Second quote"></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 · Founder quote — Measured area 0.366% · moving frames 17/23Original by GODRICH · SCSS and React ship in the zip · See the password
A quotation mark that wobbles
It's the founder or CEO message block that sits inside a press room or an about page's leadership section.
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');
});
});
08Milestone timeline
Filling the track to a stop
Clicking any of the four stops on the horizontal line actually fills the track up to that point with a real scaleX transform, keyed to the stop's position out of four, and swaps the paragraph above it to that year's text.

<main class="stage">
<section class="mt is-demo" data-active="0">
<p class="mt__desc" role="status">In 2019, three founders launched our first product from a tiny office.</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">Founded</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">Series 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">Global expansion</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">1M users</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 = [
'In 2019, three founders launched our first product from a tiny office.',
'In 2021, we raised a Series A and grew the team tenfold.',
'In 2023, we opened offices in Japan and the US and went global.',
'In 2026, we passed one million registered users.'
];
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 · Milestone timeline — Measured area 0.438% · moving frames 7/23Original by GODRICH · SCSS and React ship in the zip · See the password
The dot that keeps pulsing
The active dot keeps pulsing with a box-shadow ring the whole time it's selected, which makes this fit a company history section or a press room's "how we got here" block.
.mt__fill {
position: absolute; left: 0; right: 0; top: 4px;
height: 3px;
background: $color;
transform: scaleX(.25);
transform-origin: left;
transition: transform $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);
}
09Press footer
A real regex on submit
Submitting the newsletter form runs a real regex check against the typed address rather than pretending to validate.

<main class="stage">
<footer class="pf">
<div class="pf__top">
<p class="pf__heading">Get press releases by email</p>
<form class="pf__form" novalidate>
<input class="pf__input" type="email" placeholder="[email protected]" aria-label="Email">
<button class="pf__submit" type="submit">
<span class="pf__submit-label">Subscribe</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">Press</p><a href="#">Press releases</a><a href="#">Media kit</a></div>
<div class="pf__col"><p class="pf__col-title">Company</p><a href="#">About</a><a href="#">Careers</a></div>
<div class="pf__col"><p class="pf__col-title">Follow</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;
}
}// 실제 동작: 제출 시 실제로 Email 형식을 검사한다. 통과하면 체크가 그려지고, 틀리면 입력칸이 흔들린다.
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 = 'Subscribed — a confirmation email is on the way.';
} else {
input.classList.remove('is-shake');
void input.offsetWidth;
input.classList.add('is-shake');
status.textContent = 'Please enter a valid email address.';
}
});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 · Press footer — Measured area 0.578% · moving frames 15/23Original by GODRICH · SCSS and React ship in the zip · See the password
The columns stay put
A passing address draws a checkmark onto the submit button with stroke-dashoffset, while a failing one shakes the input left and right with translateX — the three link columns underneath stay put either way, which is what a press room or media page needs at the very bottom.
.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 { 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__form.is-done .pf__check { stroke-dashoffset: 0; opacity: 1; }
Where it breaks — the trap
A background that hid the orbs
The real trap here wasn't an animation at all — it was a background color hiding a decoration sitting right behind it. The media contact card (06) originally had an opaque cream background, and the two drifting orbs behind it sit at z-index: -1, so they were rendering completely behind that opaque layer. Nothing looked broken on screen, but almost no pixels were actually changing frame to frame, which barely counted as motion at all. Removing the card's own background and letting the orbs show directly against the stage color fixed it — a reminder that z-index: -1 only works as a decoration if whatever sits above it is transparent.
Padding that stacked on height
A second, more mundane trap showed up in the release list (02) and the media kit (03) at the 320×200 breakpoint. Both fold rows open and shut with max-height, and without box-sizing: border-box on the folding element, the row's padding stacks on top of the height you set rather than inside it, so a max-height: 30px row with 8px of vertical padding actually renders at 38px. That extra height was invisible at the full 480×300 canvas but pushed the layout into a silent vertical scroll once the width dropped to 320px — adding box-sizing: border-box and trimming the padding inside a @media (max-width: 340px), (max-height: 220px) block cleared it. The mentions bar (04) sidesteps a related trap by design: give a duplicated marquee track a gap instead of margin-right on each item and the seam between the two copies comes up short by gap ÷ 2. Open the zip with 7a6mw6kt typed exactly as it appears here and you'll find every one of the nine already built the way this page describes, margin-right included.
Publishing numbers as pictures only, where no search engine can read them, comes back the moment real data is involved in the dashboard chart interactions.
Accessibility (reduced-motion)
State survives reduced motion
All nine respect prefers-reduced-motion: reduce, and each one keeps its actual state rather than just freezing mid-animation — the release list stays filtered, the timeline's fill bar stays at whatever point it reached, and the stat counter keeps counting up since the number itself carries information, with only its accent pulse turning off.
Native controls and status regions
The media kit's checkboxes (03) are native input[type="checkbox"] elements, so Tab and Space operate them with no extra script, and its result text plus the contact card's copy hint (06) and the footer's submit result (09) all live in a role="status" region with aria-live="polite" so a screen reader announces the outcome the moment it happens. The timeline (08) updates a real aria-current="step" on the active stop, and the founder quote's dots (07) sit in a role="group" with aria-selected tracking which slide is showing.
Clipboard API and its fallback
The clipboard copy in 06 follows MDN's Clipboard API reference and falls back to a hidden textarea plus execCommand('copy') in browsers that don't support it. More layout sections and static templates live on the template category hub, and what this site does and doesn't sell is on the about page.
FAQ
Can I drop real logos and photos into these media page examples?
Yes — the avatar circle, wordmark, and all eight outlet marks are CSS shapes built from this site's own $subject-ink, $subject-blue, and $subject-cream tokens, not real image files, so replacing any of them with an actual <img> or logo file is a straight swap that leaves the layout and JavaScript untouched.
Does the media kit download actually send files, or is it just decorating the click?
The checkbox state, the button's enabled/disabled state, and the per-row checkmark are all real DOM state changes, not a fake animation loop. What it doesn't do is fetch a real file, since the demo has nothing to download — pointing each row at a real asset URL is the only change needed to make it functional.
How different is the React version from the plain CSS one?
The behavior matches. Which chip is active, which files are checked, which quote is showing, and which timeline stop is selected all live in useState instead of DOM attributes and classes, and the three SCSS variables — duration, easing, color — arrive as component props that get forwarded onto the section as CSS custom properties. The stat counter's requestAnimationFrame loop runs inside a useEffect with its own cleanup, so both versions count up in lockstep. When the audience to convince is a customer rather than a reporter, the landing sections that build trust are that set.









