9 mẫu press page design — dán vào landing
Press page design là bộ code gom sẵn từng khối của trang báo chí: press release, media kit, liên hệ người phụ trách.
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
Tự chạy · bấm ô để tới mục đó · cả 9 trong một zip
- 01 Hero phòng báo chí
- 02 Danh sách thông cáo báo chí
- 03 Tải media kit
- 04 Dải logo báo chí
- 05 Bộ đếm số liệu
- 06 Card liên hệ báo chí
- 07 Trích dẫn nhà sáng lập
- 08 Timeline cột mốc
- 09 Footer press room
Chín mục xếp theo đúng trình tự nhà báo và nhà đầu tư lướt qua một press room. Đầu tiên là ấn tượng ban đầu (hero), kế đến tin mới nhất (danh sách press release), rồi tư liệu lấy về để viết bài (media kit), nơi đã đăng tải (thanh logo truyền thông), quy mô chứng minh bằng con số (điểm nhấn số liệu), liên hệ trực tiếp người phụ trách (thẻ liên hệ truyền thông), giọng nói thương hiệu (lời người sáng lập), chặng đường đã đi (timeline thành tựu), và cuối cùng là nhận tin cho lần sau (footer đăng ký). Ô mặt người phụ trách và ô logo nhà báo là placeholder hình khối vẽ bằng ba token mực·xanh·kem của site này, thay bằng ảnh thật thì bố cục và lề vẫn giữ nguyên. Ai đang gom press page design examples để dựng trang cho công ty mình thì cứ lấy đúng thứ tự này làm khung sườn.
01Hero truyền thông
Màn hình mở đầu của press room
Ba vòng tròn mờ phía sau sân khấu cứ trôi mãi, còn phía trước wordmark (placeholder hình khối)·tiêu đề lớn·phụ đề·hai nút CTA cùng nằm gọn trong một khung hình.

<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">Dành cho báo chí & đối tác<br>Tin mới nhất</h1>
<p class="ph__sub">Thông cáo báo chí, media kit và liên hệ đều gộp tại một chỗ.</p>
<div class="ph__row">
<a class="ph__cta" href="#">Xem thông cáo</a>
<button class="ph__sub-btn" type="button" aria-pressed="false">
<span class="ph__sub-ic" aria-hidden="true"></span>
Đăng ký nhận tin
</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;
}
}// 실제 동작: 클릭마다 Đăng ký 상태가 실제로 바뀐다(아이콘 채움 + 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 · Hero phòng báo chí — Diện tích đo được 30.411% · khung hình chuyển động 18/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · Xem mật khẩu
Nút đăng ký đổi trạng thái thật
Mỗi lần nhấn nút đăng ký bên phải, trạng thái đăng ký và độ tô đặc của biểu tượng thay đổi theo.
.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);
}
02Danh sách press release
Lọc biểu mẫu press release theo phân loại
Nhấp vào chip phân loại (tất cả·sản phẩm·hợp tác·giải thưởng) là những dòng sai loại gập lại thật, chỉ còn đúng phần được chọn.

<main class="stage">
<section class="rl is-demo">
<div class="rl__filters" role="group" aria-label="Phân loại">
<button class="rl__chip" type="button" data-cat="all" aria-pressed="true">Tất cả</button>
<button class="rl__chip" type="button" data-cat="product" aria-pressed="false">Sản phẩm</button>
<button class="rl__chip" type="button" data-cat="partner" aria-pressed="false">Hợp tác</button>
<button class="rl__chip" type="button" data-cat="award" aria-pressed="false">Giải thưởng</button>
</div>
<ul class="rl__list">
<li class="rl__row" data-cat="product"><span class="rl__date">08.20</span><span class="rl__title">Ra mắt tính năng workspace mới</span><span class="rl__tag">Sản phẩm</span></li>
<li class="rl__row" data-cat="partner"><span class="rl__date">07.02</span><span class="rl__title">Ký hợp tác phân phối với Công ty B</span><span class="rl__tag">Hợp tác</span></li>
<li class="rl__row" data-cat="award"><span class="rl__date">06.15</span><span class="rl__title">Đoạt giải Startup of the Year</span><span class="rl__tag">Giải thưởng</span></li>
<li class="rl__row" data-cat="product"><span class="rl__date">05.30</span><span class="rl__title">Công bố bản cập nhật 3.0</span><span class="rl__tag">Sản phẩm</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;
}
}// 실제 동작: Phân loại 칩을 클릭하면 그 분류가 아닌 행이 실제로 접히고 칩 상태가 배타적으로 바뀐다.
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 · Danh sách thông cáo báo chí — Diện tích đo được 0.271% · khung hình chuyển động 16/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · Xem mật khẩu
Thanh accent quét tuần tự
Thanh accent bên trái từng dòng sáng dần theo thứ tự, tạo một chuyển động quét luôn lặp lại.
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);
});
});
});
03Tải media kit
Chỉ nhận những file được chọn
Tích chọn logo pack·hướng dẫn thương hiệu·ảnh chụp sản phẩm thì nút tải file được chọn bật lên thật.

<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">Bộ logo (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">Hướng dẫn thương hiệu (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">Ảnh sản phẩm (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>
Tải tệp đã chọn
</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;
}
}// 실제 동작: 체크한 Đã nhận 수만큼 버튼 라벨이 바뀌고, 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 ? ' Tải tệp đã chọn (' + checkedFiles.length + ')' : ' Tải tệp đã chọn';
}
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 = 'Đã nhận ' + checkedFiles.length + ' tệp.';
});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 · Tải media kit — Diện tích đo được 0.756% · khung hình chuyển động 22/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · Xem mật khẩu
Nhấn xong đổi gì
Sau khi nhấn, từng file được chọn có biểu tượng check vẽ vào và số file đã nhận được thông báo bằng dòng chữ.
.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; }
04Thanh logo truyền thông
Tám biểu tượng chạy không đứt quãng
Tám dấu hiệu nhà báo vẽ bằng hình khối (không phải logo thật) nối thành một hàng và trôi đều không điểm gãy.

<main class="stage">
<section class="mb" aria-label="Báo chí đưa tin">
<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">Tạm dừng dòng chạy</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 · Dải logo báo chí — Diện tích đo được 8.057% · khung hình chuyển động 23/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · Xem mật khẩu
Rê chuột là dừng ngay tại chỗ
Đưa con trỏ lên hoặc giữ nút bên cạnh thì dòng chảy đứng lại đúng vị trí đó.
// track không dùng gap — mỗi logo nhận margin-right riêng
.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%); }
}
05Điểm nhấn số liệu
Từ 0 đến giá trị đích
Ba con số lớn trên ba thẻ chạy từ 0 lên đúng giá trị đích thật (số bài báo·số đối tác truyền thông·lượt tải tích lũy) ngay khi mở trang, rồi dừng lại.

<main class="stage">
<section class="sc">
<div class="sc__card">
<p class="sc__num" data-target="128">0</p>
<p class="sc__label">Số lần lên báo</p>
</div>
<div class="sc__card">
<p class="sc__num" data-target="42">0</p>
<p class="sc__label">Số đầu báo hợp tác</p>
</div>
<div class="sc__card">
<p class="sc__num" data-target="3200">0</p>
<p class="sc__label">Tổng lượt tải</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 · Bộ đếm số liệu — Diện tích đo được 2.675% · khung hình chuyển động 21/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · Xem mật khẩu
Thanh accent phía trên thẻ
Thanh accent trên đầu thẻ quét tuần tự và lặp lại liên tục.
.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;
}
06Thẻ liên hệ truyền thông
Email được sao chép thật
Dưới tên·chức danh người phụ trách truyền thông, nhấn nút email là địa chỉ vào clipboard thật.

<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">Trưởng nhóm PR & Media</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 = 'Đã copy địa chỉ email.';
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 · Card liên hệ báo chí — Diện tích đo được 3.774% · khung hình chuyển động 6/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · Xem mật khẩu
Check biến mất sau 1,6 giây
Trên nút có biểu tượng check vẽ ra thoáng qua rồi biến mất sau 1,6 giây.
.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; }
07Lời người sáng lập
Hai đoạn trích đổi chỗ qua hai chấm
Hai câu trích dẫn của người sáng lập đổi chỗ qua lại bằng hai nút chấm tròn.

<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">"Chúng tôi tin nhóm nhỏ cũng đưa được tin nhanh như các tờ báo lớn."</blockquote>
<figcaption class="fq__who"><span class="fq__name">Lee Dohyun</span> · Đồng sáng lập & CEO</figcaption>
</figure>
<figure class="fq__slide" data-i="1">
<blockquote class="fq__quote">"Press room không phải chỗ quảng bá — đó là nơi xác lập niềm tin."</blockquote>
<figcaption class="fq__who"><span class="fq__name">Park Seoyeon</span> · Đồng sáng lập & CTO</figcaption>
</figure>
</div>
<div class="fq__dots" role="group" aria-label="Chọn trích dẫn">
<button class="fq__dot is-active" type="button" data-i="0" aria-selected="true" aria-label="Trích dẫn thứ nhất"></button>
<button class="fq__dot" type="button" data-i="1" aria-selected="false" aria-label="Trích dẫn thứ hai"></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 · Trích dẫn nhà sáng lập — Diện tích đo được 0.366% · khung hình chuyển động 17/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · Xem mật khẩu
Dấu ngoặc kép khẽ lay động
Nhấp một chấm là câu trích dẫn đó chuyển qua bằng hiệu ứng crossfade thật, còn dấu ngoặc kép lớn trang trí đung đưa dịu nhẹ.
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');
});
});
08Timeline thành tựu
Tô đến đúng điểm được nhấp
Bốn mốc trên trục ngang (thành lập·Series A·mở rộng ra nước ngoài·1 triệu người dùng) nhấp vào đâu là thanh tiến trình tô scaleX đến đúng đấy.

<main class="stage">
<section class="mt is-demo" data-active="0">
<p class="mt__desc" role="status">Năm 2019, ba nhà sáng lập ra mắt sản phẩm đầu tiên từ một văn phòng nhỏ.</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">Thành lập</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">Vòng 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">Tiến ra quốc tế</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">1 triệu người dùng</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 = [
'Năm 2019, ba nhà sáng lập ra mắt sản phẩm đầu tiên từ một văn phòng nhỏ.',
'Năm 2021, gọi vốn thành công vòng Series A và đội ngũ lớn gấp mười.',
'Năm 2023, mở chi nhánh tại Nhật và Mỹ, bắt đầu tiến ra quốc tế.',
'Năm 2026, vượt mốc một triệu người dùng đã đăng ký.'
];
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 · Timeline cột mốc — Diện tích đo được 0.438% · khung hình chuyển động 7/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · Xem mật khẩu
Đoạn mô tả đổi theo
Đoạn mô tả phía trên cũng thay nội dung khớp với mốc thời gian đó.
.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);
}
09Footer truyền thông
Kiểm tra định dạng thật sự
Nhập email và nhấn đăng ký thì định dạng được kiểm tra thật: đúng thì vẽ check và hiện thông báo, sai thì ô nhập rung ngang vài nhịp.

<main class="stage">
<footer class="pf">
<div class="pf__top">
<p class="pf__heading">Nhận thông cáo báo chí qua 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">Đăng ký</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="#">Thông cáo báo chí</a><a href="#">Media kit</a></div>
<div class="pf__col"><p class="pf__col-title">Công ty</p><a href="#">Giới thiệu</a><a href="#">Tuyển dụng</a></div>
<div class="pf__col"><p class="pf__col-title">Theo dõi</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 = 'Đã đăng ký — email xác nhận đã được gửi.';
} else {
input.classList.remove('is-shake');
void input.offsetWidth;
input.classList.add('is-shake');
status.textContent = 'Vui lòng nhập email hợp lệ.';
}
});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 · Footer press room — Diện tích đo được 0.578% · khung hình chuyển động 15/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · Xem mật khẩu
Ba ô liên kết giữ nguyên vị trí
Ba ô liên kết phía dưới luôn đứng đúng chỗ.
.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; }
Mẫu trang báo chí dễ vỡ ở đâu — ba cái bẫy
Nền đặc che mất vòng tròn
Trong lúc làm chín mục này tôi vấp thật hai lần. Lần thứ nhất ở thẻ liên hệ truyền thông (06): lúc đầu sơn nền thẻ bằng màu kem đặc, thế nhưng vòng trôi phía sau nền có z-index:-1 nên bị che khuất hoàn toàn sau lớp nền đặc đó. Nhìn trên màn hình tưởng không có vấn đề gì, nhưng số pixel thật sự chuyển động gần như bằng không, gọi là animation còn thấy ngại.
Thiếu box-sizing thì sao
Bỏ nền thẻ đi cho vòng tròn lộ ra trên màu sân khấu là xong. Lần thứ hai nằm ở phần responsive của danh sách press release (02) và media kit (03): cả hai gập·mở dòng bằng max-height, mà không khai báo box-sizing thì padding cộng thẳng lên chiều cao đã đặt, chiều cao render thật vượt mức tính toán. Trên màn hình rộng 320px, chênh lệch ấy dồn lại và một thanh cuộn dọc xuất hiện âm thầm.
Khoảng cách marquee dùng margin-right
Thêm box-sizing: border-box và giảm padding cho màn hẹp là cả hai qua cổng. Thanh logo marquee (04) cũng có một bẫy quen thuộc: đặt khoảng cách giữa các mục bằng gap thì đường nối giữa hai dải (2N mục) thiếu đúng gap ÷ 2, nên mỗi vòng quay lại bị giật nhẹ một nhịp. Mẫu này cho khoảng cách bằng margin-right ngay từ đầu nên né được, và zip mở bằng mật khẩu 7a6mw6kt giữ nguyên đúng cách ấy. Tính cả hai cái bẫy trên, chín mục đang ở trạng thái mới nhất chạy chuẩn như bảng mô tả.
Khả năng tiếp cận
Khi giảm chuyển động thì trạng thái còn lại không
Cả chín mục tắt animation trang trí dưới prefers-reduced-motion: reduce nhưng giữ nguyên trạng thái — thanh tiến trình timeline dừng ở mức đã tô, danh sách đã lọc đứng gập sẵn, còn bộ đếm của điểm nhấn số liệu là thông tin nên giữ nguyên giá trị.
Điều khiển gốc và thông báo trạng thái
Ô chọn của media kit (03) là input[type="checkbox"] gốc nên Tab·Space vận hành được ngay; dòng kết quả của thẻ liên hệ (06) và footer (09) mang role="status" (aria-live) nên trình đọc màn hình đọc liền kết quả sao chép·gửi. Timeline (08) báo vị trí đang xem bằng aria-current="step", còn nút chấm của mục 07 truyền trạng thái qua role="group" và aria-selected.
Clipboard và phương án dự phòng
Phần sao chép clipboard (06) theo thông số MDN Clipboard API và rơi vào phương án textarea dự phòng trên môi trường không hỗ trợ. Các template tĩnh khác kiểu email·dashboard nằm trong danh sách chuyên mục template, và người vận hành blog này là ai thì trang giới thiệu đã ghi rõ.
FAQ — hỏi đáp nhanh
Mẫu press room có dùng logo hay ảnh thật được không?
Được, thiết kế sinh ra để làm điều đó — đó cũng là điểm phân biệt một bộ khối dùng lại được với một bản press page ui design chỉ đẹp trên ảnh chụp. Thay ô tròn avatar·ô logo nhà báo bằng file thật thì kích thước·canh lề vẫn giữ như cũ. Hình khối dùng đúng giá trị mực·xanh·kem của demo-tokens.scss, còn nền sân khấu (vàng·cam) là token khác hẳn nên hai bên không lẫn vào nhau.
Bản React khác bản CSS ở điểm nào?
Cách vận hành như nhau. Giá trị kiểu trạng thái lọc·ô chọn·trích dẫn đang mở quản lý bằng useState, còn ba biến (duration·easing·color) nhận qua props rồi truyền xuống biến CSS (--duration --easing --color). Bộ đếm của mục 05 chạy nguyên logic requestAnimationFrame đó bên trong useEffect.
Tải media kit có thật sự nhận file, hay chỉ trang trí?
Việc chọn ô, bật nút và dấu hoàn tất đều là thay đổi trạng thái thật. Chỉ có điều demo không gắn file thật, nên nhấn vào chưa bắt đầu tải xuống. Khi dùng thật, chỉ cần nối href thật vào từng dòng file là phần logic check·đếm phía sau tái sử dụng nguyên vẹn.









