ポートフォリオサイト テンプレート9選 — プロジェクト紹介セクション
ポートフォリオサイト テンプレートは、紹介ヒーロー・プロジェクトグリッド・ケース詳細・経歴タイムライン・連絡フォームなど、個人のポートフォリオページに使うセクションをまとめて配るコード集です。
01 project-intro-hero
02 project-grid
03 project-case-detail
04 project-skills-stack
05 project-experience-timeline
06 project-quote
07 project-contact-form
08 project-resume-cta
09 project-footer
自動再生 · タイルを押すとその項目へ · すべて1つのzip
9つは、採用担当者やクライアントがポートフォリオサイトを開いてから読み進める順番に並べました。まず何者かを伝え(紹介ヒーロー)、手がけた仕事を分類して見せ(プロジェクトグリッド)、代表的な1件を数字つきで深掘りし(ケース詳細ブロック)、扱える技術を並べ(技術スタック)、歩んできた経歴を示し(経歴タイムライン)、第三者の言葉で裏付けを足し(推薦の声)、連絡先を渡し(連絡フォーム)、履歴書を持ち帰らせ(履歴書CTA)、最後に他のリンクへ導きます(フッター)。顔写真や作品カットが入る場所に置いたのは実物ではなく、$subject-ink・$subject-blue・$subject-creamの3色だけで組んだ図形です。差し込むファイルを入れ替えるだけで使える ポートフォリオサイト テンプレート html なので、周りの余白や整列はそのまま残ります。
01紹介ヒーロー
3つの円が別々の軌道で
背景でぼかした円3つが、それぞれ違う軌道で絶えず漂い続けます。

<main class="stage">
<section class="ih">
<span class="ih__orb ih__orb--1" aria-hidden="true"></span>
<span class="ih__orb ih__orb--2" aria-hidden="true"></span>
<span class="ih__orb ih__orb--3" aria-hidden="true"></span>
<span class="ih__mark" aria-hidden="true"></span>
<p class="ih__eyebrow">PORTFOLIO</p>
<h1 class="ih__title">Webで作った<br>記録</h1>
<p class="ih__sub">プロジェクト事例と連絡先を一画面で確認できます。</p>
<div class="ih__row">
<a class="ih__cta" href="#">プロジェクトを見る</a>
<button class="ih__avail-btn" type="button" aria-pressed="false">
<span class="ih__avail-ic" aria-hidden="true"></span>
新しいプロジェクトを始める
</button>
</div>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-quick; // 제안받기 버튼 전환
$easing: $ease-pop;
$color: $subject-blue; // CTA·아이콘 강조색
@include stage($stage-ink);
.ih {
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;
}
// ── 배경 원 세 개: 계속 떠다니는 장식(호버 아님, 무조건 재생) ──────────────
.ih__orb {
position: absolute;
border-radius: 50%;
filter: blur(3px);
z-index: -1;
animation: ih-float-a 2s ease-in-out infinite;
}
.ih__orb--1 { width: 170px; height: 170px; left: -45px; top: -35px; background: radial-gradient(circle, $subject-blue, transparent 62%); }
.ih__orb--2 { width: 130px; height: 130px; right: -25px; top: 6px; background: radial-gradient(circle, $subject-cream, transparent 62%); opacity: .7; animation-name: ih-float-b; }
.ih__orb--3 { width: 150px; height: 150px; left: 28%; bottom: -70px; background: radial-gradient(circle, $subject-blue, transparent 62%); opacity: .95; animation-direction: reverse; }
@keyframes ih-float-a {
0%, 100% { transform: translate(0, 0) scale(1); }
50% { transform: translate($sp-10, -$sp-8) scale(1.5); }
}
@keyframes ih-float-b {
0%, 100% { transform: translate(0, 0) scale(1); }
50% { transform: translate(-$sp-8, $sp-8) scale(.6); }
}
// ── 플레이스홀더 워드마크: 실제 로고가 아니라 도형 하나 ──────────────
.ih__mark {
display: block;
width: $sp-5; height: $sp-5;
margin: 0 auto $sp-1;
background: $color;
border-radius: $r-xs;
transform: rotate(45deg);
}
.ih__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);
}
.ih__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;
}
.ih__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);
}
.ih__row { display: flex; justify-content: center; align-items: center; gap: $sp-2; flex-wrap: wrap; }
.ih__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;
}
.ih__avail-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;
}
.ih__avail-ic {
width: $sp-3; height: $sp-3;
background: currentColor;
border-radius: 50%;
opacity: .5;
transition: opacity $duration $easing, transform $duration $easing;
}
.ih__avail-btn[aria-pressed="true"] {
background: rgba(255, 247, 230, .12);
border-color: $subject-cream;
}
.ih__avail-btn[aria-pressed="true"] .ih__avail-ic {
opacity: 1;
color: $color;
transform: scale(1.2);
}
// ── 반응형: 320×200 처럼 세로가 짧은 화면에선 장식을 줄인다(BD9) ──────────────
@media (max-width: 340px), (max-height: 220px) {
.ih { padding: $sp-2 $sp-4; }
.ih__mark { display: none; }
.ih__eyebrow { display: none; }
.ih__title { font-size: 14px; line-height: 1.15; margin-bottom: $sp-1; }
.ih__sub { display: none; }
.ih__row { gap: $sp-1; }
.ih__cta, .ih__avail-btn { padding: 4px $sp-3; font-size: 10px; }
}
// ── 접근성 ──────────────────────────────────────────
@media (prefers-reduced-motion: reduce) {
.ih__orb { animation: none !important; }
.ih__avail-btn, .ih__avail-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);
}
.ih {
position: relative;
box-sizing: border-box;
width: min(400px, 92vw);
overflow: hidden;
border-radius: 16px;
padding: 12px 20px 8px;
text-align: center;
isolation: isolate;
}
.ih__orb {
position: absolute;
border-radius: 50%;
filter: blur(3px);
z-index: -1;
animation: ih-float-a 2s ease-in-out infinite;
}
.ih__orb--1 {
width: 170px;
height: 170px;
left: -45px;
top: -35px;
background: radial-gradient(circle, #2f6df6, transparent 62%);
}
.ih__orb--2 {
width: 130px;
height: 130px;
right: -25px;
top: 6px;
background: radial-gradient(circle, #fff7e6, transparent 62%);
opacity: 0.7;
animation-name: ih-float-b;
}
.ih__orb--3 {
width: 150px;
height: 150px;
left: 28%;
bottom: -70px;
background: radial-gradient(circle, #2f6df6, transparent 62%);
opacity: 0.95;
animation-direction: reverse;
}
@keyframes ih-float-a {
0%, 100% {
transform: translate(0, 0) scale(1);
}
50% {
transform: translate(40px, -32px) scale(1.5);
}
}
@keyframes ih-float-b {
0%, 100% {
transform: translate(0, 0) scale(1);
}
50% {
transform: translate(-32px, 32px) scale(0.6);
}
}
.ih__mark {
display: block;
width: 20px;
height: 20px;
margin: 0 auto 4px;
background: #2f6df6;
border-radius: 4px;
transform: rotate(45deg);
}
.ih__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);
}
.ih__title {
margin: 0 0 4px;
font-weight: 800;
font-size: clamp(16px, 4.6vw, 19px);
line-height: 1.14;
font-family: inherit;
color: #fff7e6;
}
.ih__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);
}
.ih__row {
display: flex;
justify-content: center;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.ih__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;
}
.ih__avail-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);
}
.ih__avail-ic {
width: 12px;
height: 12px;
background: currentColor;
border-radius: 50%;
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);
}
.ih__avail-btn[aria-pressed=true] {
background: rgba(255, 247, 230, 0.12);
border-color: #fff7e6;
}
.ih__avail-btn[aria-pressed=true] .ih__avail-ic {
opacity: 1;
color: #2f6df6;
transform: scale(1.2);
}
@media (max-width: 340px), (max-height: 220px) {
.ih {
padding: 8px 16px;
}
.ih__mark {
display: none;
}
.ih__eyebrow {
display: none;
}
.ih__title {
font-size: 14px;
line-height: 1.15;
margin-bottom: 4px;
}
.ih__sub {
display: none;
}
.ih__row {
gap: 4px;
}
.ih__cta, .ih__avail-btn {
padding: 4px 12px;
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.ih__orb {
animation: none !important;
}
.ih__avail-btn, .ih__avail-ic {
transition: none;
}
}// 실제 동작: 클릭마다 "제안받기" 상태가 실제로 바뀐다(아이콘 채움 + aria-pressed).
var btn = document.querySelector('.ih__avail-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 "./ProjectIntroHero.module.scss";
export type ProjectIntroHeroProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function ProjectIntroHero({ duration = 220, easing = "cubic-bezier(.2,1.4,.4,1)", color = "#2f6df6" }: ProjectIntroHeroProps) {
const [available, setAvailable] = useState(false);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<section className={styles.ih} 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}>PORTFOLIO</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.availBtn}
aria-pressed={available}
onClick={() => setAvailable((v) => !v)}
>
<span className={styles.availIc} aria-hidden="true" />
새 프로젝트 제안받기
</button>
</div>
</section>
);
}01 · 紹介ヒーロー — 計測 累積面積 31.004% · 動いたフレーム 18/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
ボタンは実際に切り替わる
その手前に図形のワードマーク・見出し・サブ見出し・CTAボタン2つが1画面に収まり、右側の「新しい案件の相談を受け付ける」ボタンは押すたびに状態とアイコンの塗りが実際に切り替わります。
.ih__orb--1 { animation: ih-float-a 2s ease-in-out infinite; }
@keyframes ih-float-a {
0%, 100% { transform: translate(0, 0) scale(1); }
50% { transform: translate($sp-10, -$sp-8) scale(1.5); }
}
.ih__avail-btn[aria-pressed="true"] {
background: rgba(255, 247, 230, .12);
border-color: $subject-cream;
}
.ih__avail-btn[aria-pressed="true"] .ih__avail-ic {
opacity: 1;
color: $color;
transform: scale(1.2);
}
02プロジェクトグリッド
ポートフォリオ ギャラリーをタブで絞る
上部の分類タブ(すべて・Web・アプリ・デザイン)をクリックすると、黒いインジケーターがそのタブの実際の幅・位置まで移動し、選ばれていない分類のカードは実際に薄くなります。

<main class="stage">
<section class="pg is-demo">
<div class="pg__tabs" role="tablist" aria-label="プロジェクト分類">
<span class="pg__indicator" aria-hidden="true"></span>
<button class="pg__tab" type="button" role="tab" data-cat="all" aria-selected="true">すべて</button>
<button class="pg__tab" type="button" role="tab" data-cat="web" aria-selected="false">Web</button>
<button class="pg__tab" type="button" role="tab" data-cat="app" aria-selected="false">アプリ</button>
<button class="pg__tab" type="button" role="tab" data-cat="design" aria-selected="false">デザイン</button>
</div>
<div class="pg__grid">
<article class="pg__card" data-cat="web"><span class="pg__thumb" aria-hidden="true"></span><p class="pg__title">Webプロジェクト A</p><p class="pg__tag">Web</p></article>
<article class="pg__card" data-cat="app"><span class="pg__thumb" aria-hidden="true"></span><p class="pg__title">アプリプロジェクト B</p><p class="pg__tag">App</p></article>
<article class="pg__card" data-cat="design"><span class="pg__thumb" aria-hidden="true"></span><p class="pg__title">デザインプロジェクト C</p><p class="pg__tag">Design</p></article>
<article class="pg__card" data-cat="web"><span class="pg__thumb" aria-hidden="true"></span><p class="pg__title">Webプロジェクト D</p><p class="pg__tag">Web</p></article>
</div>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-base; // 인디케이터·카드 필터 전환
$easing: $ease-spring;
$color: $subject-blue; // 인디케이터·활성 탭 색
@include stage($stage-yellow);
.pg { width: min(420px, 95vw); }
// ── 탭 + 인디케이터: 폭은 짐작하지 않고 클릭마다 getBoundingClientRect 로 실측(BD19) ──────────────
.pg__tabs {
position: relative;
display: flex;
gap: $sp-1;
margin: 0 0 $sp-2;
padding: $sp-1;
border-radius: $r-pill;
background: rgba(23, 20, 26, .06);
box-sizing: border-box;
}
.pg__indicator {
position: absolute;
left: $sp-1; top: $sp-1; bottom: $sp-1;
border-radius: $r-pill;
background: $subject-ink;
transition: transform $duration $easing, width $duration $easing;
}
.pg__tab {
position: relative;
flex: 0 0 auto;
padding: $sp-1 $sp-3;
border: 0;
background: none;
border-radius: $r-pill;
color: rgba($subject-ink, .72); // 옐로 면 위 5.70:1
font-weight: 700; font-size: 11px; line-height: 1.6; font-family: inherit;
cursor: pointer;
transition: color $duration $easing;
}
.pg__tab[aria-selected="true"] { color: $subject-cream; }
.pg__grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: $sp-1;
}
.pg__card {
overflow: hidden;
border-radius: $r-card;
background: rgba(23, 20, 26, .04);
padding: $sp-1;
box-sizing: border-box;
transition: opacity $duration $easing, transform $duration $easing;
}
.pg__card.is-hidden { opacity: .18; transform: scale(.94); }
.pg__thumb {
position: relative;
display: block;
overflow: hidden;
height: 30px;
border-radius: $r-sm;
margin: 0 0 $sp-1;
background: rgba(23, 20, 26, .12);
}
// ── 썸네일 위 대각선 하이라이트가 계속 스치는 장식(항상 재생) ──────────────
.pg__thumb::after {
content: "";
position: absolute;
top: 0; bottom: 0; left: -60%;
width: 40%;
background: linear-gradient(100deg, transparent, rgba(255, 255, 255, .55), transparent);
animation: pg-shimmer 2s linear infinite;
}
.pg__card:nth-child(2n) .pg__thumb::after { animation-delay: 400ms; }
@keyframes pg-shimmer {
0% { transform: translateX(0); }
100% { transform: translateX(300%); }
}
.pg__title { margin: 0; font-weight: 700; font-size: 10px; line-height: 1.3; font-family: inherit; color: $subject-ink; }
.pg__tag { margin: $sp-1 0 0; font-weight: 600; font-size: 9px; line-height: 1; font-family: inherit; color: rgba($subject-ink, .72); } // 카드 면 위 5.85:1
// ── 반응형(BD9) ──────────────────────────────────────────
@media (max-width: 340px), (max-height: 220px) {
.pg__tabs { margin-bottom: $sp-1; }
.pg__tab { padding: 4px $sp-2; font-size: 10px; line-height: 1.2; }
.pg__thumb { height: 26px; }
.pg__grid { gap: $sp-1; }
}
// ── 접근성 ──────────────────────────────────────────
@media (prefers-reduced-motion: reduce) {
.pg__indicator, .pg__tab, .pg__card { transition: none; }
.pg__thumb::after { 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);
}
.pg {
width: min(420px, 95vw);
}
.pg__tabs {
position: relative;
display: flex;
gap: 4px;
margin: 0 0 8px;
padding: 4px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.06);
box-sizing: border-box;
}
.pg__indicator {
position: absolute;
left: 4px;
top: 4px;
bottom: 4px;
border-radius: 999px;
background: #17141a;
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1), width 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.pg__tab {
position: relative;
flex: 0 0 auto;
padding: 4px 12px;
border: 0;
background: none;
border-radius: 999px;
color: rgba(23, 20, 26, 0.72);
font-weight: 700;
font-size: 11px;
line-height: 1.6;
font-family: inherit;
cursor: pointer;
transition: color 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.pg__tab[aria-selected=true] {
color: #fff7e6;
}
.pg__grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 4px;
}
.pg__card {
overflow: hidden;
border-radius: 16px;
background: rgba(23, 20, 26, 0.04);
padding: 4px;
box-sizing: border-box;
transition: opacity 300ms cubic-bezier(0.2, 0.8, 0.2, 1), transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.pg__card.is-hidden {
opacity: 0.18;
transform: scale(0.94);
}
.pg__thumb {
position: relative;
display: block;
overflow: hidden;
height: 30px;
border-radius: 8px;
margin: 0 0 4px;
background: rgba(23, 20, 26, 0.12);
}
.pg__thumb::after {
content: "";
position: absolute;
top: 0;
bottom: 0;
left: -60%;
width: 40%;
background: linear-gradient(100deg, transparent, rgba(255, 255, 255, 0.55), transparent);
animation: pg-shimmer 2s linear infinite;
}
.pg__card:nth-child(2n) .pg__thumb::after {
animation-delay: 400ms;
}
@keyframes pg-shimmer {
0% {
transform: translateX(0);
}
100% {
transform: translateX(300%);
}
}
.pg__title {
margin: 0;
font-weight: 700;
font-size: 10px;
line-height: 1.3;
font-family: inherit;
color: #17141a;
}
.pg__tag {
margin: 4px 0 0;
font-weight: 600;
font-size: 9px;
line-height: 1;
font-family: inherit;
color: rgba(23, 20, 26, 0.72);
}
@media (max-width: 340px), (max-height: 220px) {
.pg__tabs {
margin-bottom: 4px;
}
.pg__tab {
padding: 4px 8px;
font-size: 10px;
line-height: 1.2;
}
.pg__thumb {
height: 26px;
}
.pg__grid {
gap: 4px;
}
}
@media (prefers-reduced-motion: reduce) {
.pg__indicator, .pg__tab, .pg__card {
transition: none;
}
.pg__thumb::after {
animation: none !important;
}
}// 실제 동작: 탭을 클릭하면 인디케이터가 실제 탭 폭·위치(getBoundingClientRect)로 이동하고, 그 분류가 아닌 카드는 실제로 흐려진다.
var root = document.querySelector('.pg');
var tabs = root.querySelectorAll('.pg__tab');
var indicator = root.querySelector('.pg__indicator');
var cards = root.querySelectorAll('.pg__card');
function moveIndicator(tab) {
var wrapBox = tab.parentElement.getBoundingClientRect();
var box = tab.getBoundingClientRect();
indicator.style.width = box.width + 'px';
indicator.style.transform = 'translateX(' + (box.left - wrapBox.left) + 'px)';
}
tabs.forEach(function (tab) {
tab.addEventListener('click', function () {
root.classList.remove('is-demo');
tabs.forEach(function (t) { t.setAttribute('aria-selected', 'false'); });
tab.setAttribute('aria-selected', 'true');
moveIndicator(tab);
var cat = tab.getAttribute('data-cat');
cards.forEach(function (card) {
var show = cat === 'all' || card.getAttribute('data-cat') === cat;
card.classList.toggle('is-hidden', !show);
});
});
});
moveIndicator(tabs[0]);
window.addEventListener('resize', function () {
var activeTab = root.querySelector('.pg__tab[aria-selected="true"]');
if (activeTab) moveIndicator(activeTab);
});import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./ProjectGrid.module.scss";
export type ProjectGridProps = { duration?: number; easing?: string; color?: string };
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const TABS = [
{ cat: "all", label: "전체" },
{ cat: "web", label: "웹" },
{ cat: "app", label: "앱" },
{ cat: "design", label: "디자인" },
];
const CARDS = [
{ cat: "web", title: "웹 프로젝트 A", tag: "Web" },
{ cat: "app", title: "앱 프로젝트 B", tag: "App" },
{ cat: "design", title: "디자인 프로젝트 C", tag: "Design" },
{ cat: "web", title: "웹 프로젝트 D", tag: "Web" },
];
export default function ProjectGrid({ duration = 300, easing = "cubic-bezier(.2,.8,.2,1)", color = "#2f6df6" }: ProjectGridProps) {
const [active, setActive] = useState("all");
const [indicator, setIndicator] = useState<{ left: number; width: number } | null>(null);
const wrapRef = useRef<HTMLDivElement>(null);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
// 실제 동작: 탭을 클릭하면 인디케이터가 실제 탭 폭·위치(getBoundingClientRect)로 이동한다(BD19 — 값을 짐작하지 않는다).
function moveIndicator(el: HTMLButtonElement) {
const wrap = wrapRef.current;
if (!wrap) return;
const wrapBox = wrap.getBoundingClientRect();
const box = el.getBoundingClientRect();
setIndicator({ left: box.left - wrapBox.left, width: box.width });
}
useEffect(() => {
const first = wrapRef.current?.querySelector<HTMLButtonElement>('[aria-selected="true"]');
if (first) moveIndicator(first);
}, []);
return (
<section className={styles.pg} style={vars}>
<div className={styles.tabs} role="tablist" aria-label="프로젝트 분류" ref={wrapRef}>
{indicator && (
<span className={styles.indicator} style={{ transform: `translateX(${indicator.left}px)`, width: indicator.width }} aria-hidden="true" />
)}
{TABS.map((t) => (
<button
key={t.cat}
type="button"
role="tab"
className={styles.tab}
aria-selected={active === t.cat}
onClick={(e: any) => { setActive(t.cat); moveIndicator(e.currentTarget); }}
>
{t.label}
</button>
))}
</div>
<div className={styles.grid}>
{CARDS.map((c, i) => (
<article key={i} className={`${styles.card} ${active !== "all" && active !== c.cat ? styles.hidden : ""}`}>
<span className={styles.thumb} aria-hidden="true" />
<p className={styles.title}>{c.title}</p>
<p className={styles.tag}>{c.tag}</p>
</article>
))}
</div>
</section>
);
}02 · プロジェクトグリッド — 計測 累積面積 14.028% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
選ばれていないカードは薄くなる
インジケーターの位置は決め打ちのpxではなく、クリックのたびにgetBoundingClientRectで測り直した値です。
function moveIndicator(tab) {
var wrapBox = tab.parentElement.getBoundingClientRect();
var box = tab.getBoundingClientRect();
indicator.style.width = box.width + 'px';
indicator.style.transform = 'translateX(' + (box.left - wrapBox.left) + 'px)';
}
tabs.forEach(function (tab) {
tab.addEventListener('click', function () {
root.classList.remove('is-demo');
tabs.forEach(function (t) { t.setAttribute('aria-selected', 'false'); });
tab.setAttribute('aria-selected', 'true');
moveIndicator(tab);
var cat = tab.getAttribute('data-cat');
cards.forEach(function (card) {
var show = cat === 'all' || card.getAttribute('data-cat') === cat;
card.classList.toggle('is-hidden', !show);
});
});
});
03ケース詳細ブロック
パネルが開いて数字が上がる
「結果を見る」を押すとパネルが実際に開き、転換率・制作期間・トラフィック増加の3つの数字が0から目標値まで上がっていきます。

<main class="stage">
<section class="cd">
<span class="cd__accent" aria-hidden="true"></span>
<p class="cd__eyebrow">CASE STUDY</p>
<h2 class="cd__title">決済フローの再設計</h2>
<p class="cd__blurb">決済を5ステップから3ステップに減らし、離脱ポイントを見直しました。</p>
<button class="cd__toggle" type="button" aria-expanded="false" aria-controls="cd-panel">
結果を見る
</button>
<div class="cd__panel" id="cd-panel" role="status" aria-live="polite">
<div class="cd__stat"><p class="cd__num" data-target="38" data-suffix="%">0%</p><p class="cd__label">コンバージョン率が上昇</p></div>
<div class="cd__stat"><p class="cd__num" data-target="6" data-suffix="週間">0週間</p><p class="cd__label">制作期間</p></div>
<div class="cd__stat"><p class="cd__num" data-target="210" data-suffix="%">0%</p><p class="cd__label">トラフィック増加</p></div>
</div>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-base; // 패널 펼침
$easing: $ease-pop;
$color: $subject-blue; // 링·숫자 강조색
@include stage($stage-orange);
.cd {
width: min(400px, 94vw);
border-radius: $r-card;
padding: $sp-4;
box-sizing: border-box;
background: rgba(255, 255, 255, .92);
text-align: center;
}
// ── 상단 악센트 바가 계속 스캔하는 장식(항상 재생) ──────────────
.cd__accent {
display: block;
width: 160px; height: 4px;
margin: 0 auto $sp-2;
border-radius: $r-xs;
background: $color;
transform-origin: center;
animation: cd-scan 2s $easing infinite;
}
@keyframes cd-scan {
0%, 70%, 100% { transform: scaleX(.3); opacity: .5; }
30% { transform: scaleX(1); opacity: 1; }
}
.cd__eyebrow {
margin: 0 0 $sp-1;
font-weight: 700; font-size: 10px; line-height: 1; font-family: inherit;
letter-spacing: .14em;
color: rgba($subject-ink, .72); // 페이퍼 면 위 6.92:1
}
.cd__title { margin: 0 0 $sp-1; font-weight: 800; font-size: clamp(14px, 4vw, 17px); line-height: 1.2; font-family: inherit; color: $subject-ink; }
.cd__blurb { margin: 0 0 $sp-3; font-weight: 500; font-size: clamp(10px, 2.8vw, 11px); line-height: 1.4; font-family: inherit; color: rgba(23, 20, 26, .6); }
.cd__toggle {
position: relative;
display: inline-flex; align-items: center; gap: $sp-1;
padding: $sp-1 $sp-4;
border-radius: $r-pill;
border: 1px solid rgba(23, 20, 26, .18);
background: none;
color: $subject-ink;
font-weight: 700; font-size: 11px; line-height: 1.6; font-family: inherit;
cursor: pointer;
}
.cd__panel {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: $sp-2;
max-height: 0;
opacity: 0;
overflow: hidden;
box-sizing: border-box;
margin-top: 0;
transition: max-height $duration $easing, opacity $duration $easing, margin-top $duration $easing;
}
.cd__panel.is-open {
max-height: 120px;
opacity: 1;
margin-top: $sp-3;
}
.cd__num {
margin: 0;
font-weight: 800; font-size: clamp(15px, 4.4vw, 18px); line-height: 1; font-family: inherit;
color: $color;
font-variant-numeric: tabular-nums;
}
.cd__label { margin: $sp-1 0 0; font-weight: 600; font-size: 9px; line-height: 1.3; font-family: inherit; color: rgba($subject-ink, .72); } // 5.7:1 — .5 는 3.4 로 미달
// ── 반응형(BD9) ──────────────────────────────────────────
@media (max-width: 340px), (max-height: 220px) {
.cd { padding: $sp-3; }
.cd__blurb { display: none; }
.cd__label { display: none; }
}
// ── 접근성 ──────────────────────────────────────────
@media (prefers-reduced-motion: reduce) {
.cd__accent { animation: none !important; }
.cd__panel { 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);
}
.cd {
width: min(400px, 94vw);
border-radius: 16px;
padding: 16px;
box-sizing: border-box;
background: rgba(255, 255, 255, 0.92);
text-align: center;
}
.cd__accent {
display: block;
width: 160px;
height: 4px;
margin: 0 auto 8px;
border-radius: 4px;
background: #2f6df6;
transform-origin: center;
animation: cd-scan 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
@keyframes cd-scan {
0%, 70%, 100% {
transform: scaleX(0.3);
opacity: 0.5;
}
30% {
transform: scaleX(1);
opacity: 1;
}
}
.cd__eyebrow {
margin: 0 0 4px;
font-weight: 700;
font-size: 10px;
line-height: 1;
font-family: inherit;
letter-spacing: 0.14em;
color: rgba(23, 20, 26, 0.72);
}
.cd__title {
margin: 0 0 4px;
font-weight: 800;
font-size: clamp(14px, 4vw, 17px);
line-height: 1.2;
font-family: inherit;
color: #17141a;
}
.cd__blurb {
margin: 0 0 12px;
font-weight: 500;
font-size: clamp(10px, 2.8vw, 11px);
line-height: 1.4;
font-family: inherit;
color: rgba(23, 20, 26, 0.6);
}
.cd__toggle {
position: relative;
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 16px;
border-radius: 999px;
border: 1px solid rgba(23, 20, 26, 0.18);
background: none;
color: #17141a;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
font-family: inherit;
cursor: pointer;
}
.cd__panel {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
max-height: 0;
opacity: 0;
overflow: hidden;
box-sizing: border-box;
margin-top: 0;
transition: max-height 300ms cubic-bezier(0.2, 1.4, 0.4, 1), opacity 300ms cubic-bezier(0.2, 1.4, 0.4, 1), margin-top 300ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.cd__panel.is-open {
max-height: 120px;
opacity: 1;
margin-top: 12px;
}
.cd__num {
margin: 0;
font-weight: 800;
font-size: clamp(15px, 4.4vw, 18px);
line-height: 1;
font-family: inherit;
color: #2f6df6;
font-variant-numeric: tabular-nums;
}
.cd__label {
margin: 4px 0 0;
font-weight: 600;
font-size: 9px;
line-height: 1.3;
font-family: inherit;
color: rgba(23, 20, 26, 0.72);
}
@media (max-width: 340px), (max-height: 220px) {
.cd {
padding: 12px;
}
.cd__blurb {
display: none;
}
.cd__label {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.cd__accent {
animation: none !important;
}
.cd__panel {
transition: none;
}
}// 실제 동작: "結果を見る"를 누르면 패널이 실제로 펼쳐지고, 숫자 세 개가 0에서 목표값까지 올라간다.
var btn = document.querySelector('.cd__toggle');
var panel = document.querySelector('.cd__panel');
var nums = panel.querySelectorAll('.cd__num');
var hasCounted = false;
function countUp() {
if (hasCounted) return;
hasCounted = true;
var startTime = performance.now();
var duration = 900;
function easeOut(t) { return 1 - Math.pow(1 - t, 3); }
function tick(now) {
var t = Math.min(1, (now - startTime) / duration);
var e = easeOut(t);
nums.forEach(function (el) {
var target = Number(el.getAttribute('data-target'));
var suffix = el.getAttribute('data-suffix');
el.textContent = Math.round(target * e) + suffix;
});
if (t < 1) requestAnimationFrame(tick);
}
requestAnimationFrame(tick);
}
btn.addEventListener('click', function () {
var open = btn.getAttribute('aria-expanded') === 'true';
btn.setAttribute('aria-expanded', String(!open));
panel.classList.toggle('is-open', !open);
if (!open) countUp();
});import { useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./ProjectCaseDetail.module.scss";
export type ProjectCaseDetailProps = { duration?: number; easing?: string; color?: string };
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const STATS = [
{ target: 38, suffix: "%", label: "전환율 상승" },
{ target: 6, suffix: "주", label: "제작 기간" },
{ target: 210, suffix: "%", label: "트래픽 증가" },
];
export default function ProjectCaseDetail({ duration = 300, easing = "cubic-bezier(.2,1.4,.4,1)", color = "#2f6df6" }: ProjectCaseDetailProps) {
const [open, setOpen] = useState(false);
const [nums, setNums] = useState(STATS.map(() => 0));
const played = useRef(false);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
// 실제 동작: "결과 보기"를 누르면 패널이 실제로 펼쳐지고, 숫자 세 개가 0에서 목표값까지 올라간다.
function countUp() {
if (played.current) return;
played.current = true;
const start = performance.now();
const len = 900;
const easeOut = (t: number) => 1 - Math.pow(1 - t, 3);
function tick(now: number) {
const t = Math.min(1, (now - start) / len);
const e = easeOut(t);
setNums(STATS.map((s) => Math.round(s.target * e)));
if (t < 1) requestAnimationFrame(tick);
}
requestAnimationFrame(tick);
}
function toggle() {
const next = !open;
setOpen(next);
if (next) countUp();
}
return (
<section className={styles.cd} style={vars}>
<span className={styles.accent} aria-hidden="true" />
<p className={styles.eyebrow}>CASE STUDY</p>
<h2 className={styles.title}>결제 플로우 리디자인</h2>
<p className={styles.blurb}>결제 단계 5→3단계로 줄이고, 이탈 지점을 다시 그렸습니다.</p>
<button type="button" className={styles.toggle} aria-expanded={open} onClick={toggle}>
결과 보기
</button>
<div className={`${styles.panel} ${open ? styles.panelOpen : ""}`} role="status" aria-live="polite">
{STATS.map((s, i) => (
<div key={s.label} className={styles.stat}>
<p className={styles.num}>{nums[i]}{s.suffix}</p>
<p className={styles.label}>{s.label}</p>
</div>
))}
</div>
</section>
);
}03 · ケース詳細 — 計測 累積面積 0.461% · 動いたフレーム 17/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
アクセントバーは常に動く
カード上部のアクセントバーはクリックの有無にかかわらず、常にスキャンする動きを繰り返します。
function countUp() {
if (hasCounted) return;
hasCounted = true;
var startTime = performance.now();
var duration = 900;
function easeOut(t) { return 1 - Math.pow(1 - t, 3); }
function tick(now) {
var t = Math.min(1, (now - startTime) / duration);
var e = easeOut(t);
nums.forEach(function (el) {
var target = Number(el.getAttribute('data-target'));
var suffix = el.getAttribute('data-suffix');
el.textContent = Math.round(target * e) + suffix;
});
if (t < 1) requestAnimationFrame(tick);
}
requestAnimationFrame(tick);
}
04技術スタック
8個のチップが流れ続ける
図形で組んだ技術チップ8個(実物のロゴではありません)を並べたトラックが、継ぎ目を感じさせずに流れ続けます。

<main class="stage">
<section class="sk" aria-label="技術スタック">
<p class="sk__label">SKILLS</p>
<div class="sk__viewport">
<div class="sk__track">
<span class="sk__chip sk__chip--1" aria-hidden="true"></span>
<span class="sk__chip sk__chip--2" aria-hidden="true"></span>
<span class="sk__chip sk__chip--3" aria-hidden="true"></span>
<span class="sk__chip sk__chip--4" aria-hidden="true"></span>
<span class="sk__chip sk__chip--1" aria-hidden="true"></span>
<span class="sk__chip sk__chip--2" aria-hidden="true"></span>
<span class="sk__chip sk__chip--3" aria-hidden="true"></span>
<span class="sk__chip sk__chip--4" aria-hidden="true"></span>
</div>
</div>
<button class="sk__pause" type="button" aria-pressed="false">流れを止める</button>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: 13s; // 트랙 한 바퀴 시간 (근접값 아님 — 마퀴 전용 값)
$easing: linear;
$color: $subject-ink; // 칩 마크 색
@include stage($stage-paper);
.sk { width: min(440px, 96vw); text-align: center; }
.sk__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); // 흰 면 위 7.26:1
}
.sk__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 로 간격을 준다
.sk__track {
display: flex;
width: max-content;
animation: sk-scroll $duration $easing infinite;
}
.sk.is-paused .sk__track,
.sk__viewport:hover .sk__track { animation-play-state: paused; }
@keyframes sk-scroll {
from { transform: translateX(0); }
to { transform: translateX(-50%); }
}
.sk__chip {
flex: 0 0 auto;
width: 68px; height: 32px;
margin-right: $sp-5;
border-radius: $r-sm;
background: rgba(23, 20, 26, .06);
display: flex; align-items: center; justify-content: center;
gap: $sp-1;
}
.sk__chip::before {
content: "";
width: 12px; height: 12px;
background: $color;
}
.sk__chip::after {
content: "";
width: 24px; height: 6px;
border-radius: $r-xs;
background: rgba(23, 20, 26, .28);
}
.sk__chip--1::before { border-radius: 50%; }
.sk__chip--2::before { border-radius: $r-xs; transform: rotate(45deg); }
.sk__chip--3::before { clip-path: polygon(50% 0%, 0% 100%, 100% 100%); border-radius: 0; }
.sk__chip--4::before { border-radius: $r-pill; width: 16px; background: $subject-blue; }
.sk__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;
}
.sk__pause[aria-pressed="true"] { background: rgba(23, 20, 26, .08); color: $subject-ink; }
// ── 접근성 ──────────────────────────────────────────
@media (prefers-reduced-motion: reduce) {
.sk__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);
}
.sk {
width: min(440px, 96vw);
text-align: center;
}
.sk__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);
}
.sk__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);
}
.sk__track {
display: flex;
width: max-content;
animation: sk-scroll 13s linear infinite;
}
.sk.is-paused .sk__track,
.sk__viewport:hover .sk__track {
animation-play-state: paused;
}
@keyframes sk-scroll {
from {
transform: translateX(0);
}
to {
transform: translateX(-50%);
}
}
.sk__chip {
flex: 0 0 auto;
width: 68px;
height: 32px;
margin-right: 20px;
border-radius: 8px;
background: rgba(23, 20, 26, 0.06);
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
}
.sk__chip::before {
content: "";
width: 12px;
height: 12px;
background: #17141a;
}
.sk__chip::after {
content: "";
width: 24px;
height: 6px;
border-radius: 4px;
background: rgba(23, 20, 26, 0.28);
}
.sk__chip--1::before {
border-radius: 50%;
}
.sk__chip--2::before {
border-radius: 4px;
transform: rotate(45deg);
}
.sk__chip--3::before {
clip-path: polygon(50% 0%, 0% 100%, 100% 100%);
border-radius: 0;
}
.sk__chip--4::before {
border-radius: 999px;
width: 16px;
background: #2f6df6;
}
.sk__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;
}
.sk__pause[aria-pressed=true] {
background: rgba(23, 20, 26, 0.08);
color: #17141a;
}
@media (prefers-reduced-motion: reduce) {
.sk__track {
animation: none !important;
transform: translateX(0);
}
}// 실제 동작: 버튼을 누르거나 마우스를 올리면 흐름이 실제로 멈춘다(터치 기기는 버튼으로).
var root = document.querySelector('.sk');
var btn = document.querySelector('.sk__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 "./ProjectSkillsStack.module.scss";
export type ProjectSkillsStackProps = { duration?: number; easing?: string; color?: string };
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const SHAPES = [1, 2, 3, 4, 1, 2, 3, 4];
export default function ProjectSkillsStack({ duration = 13, easing = "linear", color = "#17141a" }: ProjectSkillsStackProps) {
const [paused, setPaused] = useState(false);
const vars: Vars = { "--duration": `${duration}s`, "--easing": easing, "--color": color };
return (
<section className={`${styles.sk} ${paused ? styles.paused : ""}`} style={vars} aria-label="기술 스택">
<p className={styles.label}>SKILLS</p>
<div className={styles.viewport}>
<div className={styles.track}>
{SHAPES.map((n, i) => (
<span key={i} className={`${styles.chip} ${styles[`chip${n}`]}`} aria-hidden="true" />
))}
</div>
</div>
<button
type="button"
className={styles.pause}
aria-pressed={paused}
onClick={() => setPaused((v) => !v)}
>
흐름 멈추기
</button>
</section>
);
}04 · 技術スタック — 計測 累積面積 8.058% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
その場で止める
カーソルを乗せるか横のボタンを押している間だけ、その場で流れが止まります。
// トラックそのものには gap を使わない — 項目ごとに margin-right で間隔を確保する
.sk__track { display: flex; width: max-content; animation: sk-scroll $duration $easing infinite; }
.sk.is-paused .sk__track,
.sk__viewport:hover .sk__track { animation-play-state: paused; }
@keyframes sk-scroll {
from { transform: translateX(0); }
to { transform: translateX(-50%); }
}
.sk__chip {
flex: 0 0 auto;
width: 68px; height: 32px;
margin-right: $sp-5;
border-radius: $r-sm;
background: rgba(23, 20, 26, .06);
}
05経歴タイムライン
進捗線が実際に伸びる
縦線上に並んだ経歴の4地点をクリックすると、その地点までの進捗線がscaleYで実際に伸びて塗りつぶされます。

<main class="stage">
<section class="et is-demo" data-active="0">
<p class="et__desc" role="status">2023年、プロジェクトAを最初から最後までリードしました。</p>
<div class="et__track">
<span class="et__fill" aria-hidden="true"></span>
<ol class="et__list">
<li><button class="et__stop" type="button" data-i="0" aria-current="step">
<span class="et__dot" aria-hidden="true"></span><span class="et__year">2023</span><span class="et__role">プロジェクトA リード</span>
</button></li>
<li><button class="et__stop" type="button" data-i="1">
<span class="et__dot" aria-hidden="true"></span><span class="et__year">2022</span><span class="et__role">プロジェクトB 参加</span>
</button></li>
<li><button class="et__stop" type="button" data-i="2">
<span class="et__dot" aria-hidden="true"></span><span class="et__year">2021</span><span class="et__role">プロジェクトC インターン</span>
</button></li>
<li><button class="et__stop" type="button" data-i="3">
<span class="et__dot" aria-hidden="true"></span><span class="et__year">2020</span><span class="et__role">専攻修了</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-ink);
.et { width: min(360px, 94vw); }
.et__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-cream;
}
.et__track { position: relative; padding-left: $sp-5; }
.et__track::before {
content: "";
position: absolute; left: 4px; top: 4px; bottom: 4px;
width: 3px;
border-radius: $r-xs;
background: rgba(255, 247, 230, .16);
}
.et__fill {
position: absolute; left: 4px; top: 4px; bottom: 4px;
width: 3px;
border-radius: $r-xs;
background: $color;
transform: scaleY(.25);
transform-origin: top; // 규칙8: 진행선 위에서 아래로
transition: transform $duration $easing;
}
.et__list { position: relative; list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: $sp-4; }
.et__stop {
position: relative;
display: flex; align-items: center; gap: $sp-2;
border: 0; padding: 0; background: none; cursor: pointer;
text-align: left;
}
.et__dot {
flex: 0 0 auto;
width: 10px; height: 10px;
margin-left: -16px;
border-radius: 50%;
background: rgba(255, 247, 230, .3);
transition: background $duration $easing, transform $duration $easing, box-shadow $duration $easing;
}
.et__stop[aria-current="step"] .et__dot {
background: $color;
transform: scale(1.3);
box-shadow: 0 0 0 4px rgba(47, 109, 246, .22);
}
.et__year { font-weight: 800; font-size: 10px; line-height: 1; font-family: inherit; color: $subject-cream; min-width: 30px; }
.et__role { font-weight: 600; font-size: 11px; line-height: 1.3; font-family: inherit; color: rgba(255, 247, 230, .68); }
// ── 데모 루프: 현재 지점 점이 계속 펄스한다(항상 재생) ──────────────
.et.is-demo .et__stop[aria-current="step"] .et__dot { animation: et-pulse 2s $easing infinite; }
@keyframes et-pulse {
0%, 100% { box-shadow: 0 0 0 4px rgba(47, 109, 246, .22); }
50% { box-shadow: 0 0 0 8px rgba(47, 109, 246, .1); }
}
// ── 반응형(BD9) ──────────────────────────────────────────
@media (max-width: 340px), (max-height: 220px) {
.et__desc { min-height: 0; margin-bottom: $sp-2; font-size: 10px; }
.et__list { gap: $sp-2; }
.et__role { display: none; }
}
// ── 접근성 ──────────────────────────────────────────
@media (prefers-reduced-motion: reduce) {
.et__fill, .et__dot { transition: none; }
.et.is-demo .et__stop[aria-current="step"] .et__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: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.et {
width: min(360px, 94vw);
}
.et__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: #fff7e6;
}
.et__track {
position: relative;
padding-left: 20px;
}
.et__track::before {
content: "";
position: absolute;
left: 4px;
top: 4px;
bottom: 4px;
width: 3px;
border-radius: 4px;
background: rgba(255, 247, 230, 0.16);
}
.et__fill {
position: absolute;
left: 4px;
top: 4px;
bottom: 4px;
width: 3px;
border-radius: 4px;
background: #2f6df6;
transform: scaleY(0.25);
transform-origin: top;
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.et__list {
position: relative;
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 16px;
}
.et__stop {
position: relative;
display: flex;
align-items: center;
gap: 8px;
border: 0;
padding: 0;
background: none;
cursor: pointer;
text-align: left;
}
.et__dot {
flex: 0 0 auto;
width: 10px;
height: 10px;
margin-left: -16px;
border-radius: 50%;
background: rgba(255, 247, 230, 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);
}
.et__stop[aria-current=step] .et__dot {
background: #2f6df6;
transform: scale(1.3);
box-shadow: 0 0 0 4px rgba(47, 109, 246, 0.22);
}
.et__year {
font-weight: 800;
font-size: 10px;
line-height: 1;
font-family: inherit;
color: #fff7e6;
min-width: 30px;
}
.et__role {
font-weight: 600;
font-size: 11px;
line-height: 1.3;
font-family: inherit;
color: rgba(255, 247, 230, 0.68);
}
.et.is-demo .et__stop[aria-current=step] .et__dot {
animation: et-pulse 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes et-pulse {
0%, 100% {
box-shadow: 0 0 0 4px rgba(47, 109, 246, 0.22);
}
50% {
box-shadow: 0 0 0 8px rgba(47, 109, 246, 0.1);
}
}
@media (max-width: 340px), (max-height: 220px) {
.et__desc {
min-height: 0;
margin-bottom: 8px;
font-size: 10px;
}
.et__list {
gap: 8px;
}
.et__role {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.et__fill, .et__dot {
transition: none;
}
.et.is-demo .et__stop[aria-current=step] .et__dot {
animation: none !important;
}
}// 실제 동작: 경력 항목을 클릭하면 그 지점까지 세로 진행선이 scaleY 로 실제로 채워지고 설명 문단이 바뀐다.
var careerNotes = [
'2023年、プロジェクトAを最初から最後までリードしました。',
'2022年、プロジェクトBにフロントエンド担当として参加しました。',
'2021年、プロジェクトCチームでインターンとしてUIを実装しました。',
'2020年、専攻を修了し、初めてのサイドプロジェクトを始めました。'
];
var root = document.querySelector('.et');
var stops = root.querySelectorAll('.et__stop');
var desc = root.querySelector('.et__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('.et__fill').style.transform = 'scaleY(' + ((i + 1) / stops.length) + ')';
stops.forEach(function (s) { s.removeAttribute('aria-current'); });
btn.setAttribute('aria-current', 'step');
desc.textContent = careerNotes[i];
});
});import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./ProjectExperienceTimeline.module.scss";
export type ProjectExperienceTimelineProps = { duration?: number; easing?: string; color?: string };
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const STOPS = [
{ year: "2023", role: "프로젝트 A 리드", desc: "2023년, 프로젝트 A 를 처음부터 끝까지 리드했습니다." },
{ year: "2022", role: "프로젝트 B 참여", desc: "2022년, 프로젝트 B 에 프런트엔드 담당으로 참여했습니다." },
{ year: "2021", role: "프로젝트 C 인턴", desc: "2021년, 프로젝트 C 팀에서 인턴으로 UI 를 구현했습니다." },
{ year: "2020", role: "전공 수료", desc: "2020년, 전공을 마치고 첫 사이드 프로젝트를 시작했습니다." },
];
export default function ProjectExperienceTimeline({ duration = 300, easing = "cubic-bezier(.2,.8,.2,1)", color = "#2f6df6" }: ProjectExperienceTimelineProps) {
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.et : `${styles.et} ${styles.demo}`} style={vars}>
<p className={styles.desc} role="status">{STOPS[active].desc}</p>
<div className={styles.track}>
<span className={styles.fill} style={{ transform: `scaleY(${(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.role}>{s.role}</span>
</button>
</li>
))}
</ol>
</div>
</section>
);
}05 · 経歴タイムライン — 計測 累積面積 0.51% · 動いたフレーム 8/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
説明文も切り替わる
上の説明文もその時点の内容に切り替わります。
var careerNotes = [
'2023年、プロジェクトAを最初から最後までリードしました。',
'2022年、プロジェクトBにフロントエンド担当として参加しました。',
'2021年、プロジェクトCチームでインターンとしてUIを実装しました。',
'2020年、専攻を修了し、初めてのサイドプロジェクトを始めました。'
];
var root = document.querySelector('.et');
var stops = root.querySelectorAll('.et__stop');
var desc = root.querySelector('.et__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('.et__fill').style.transform = 'scaleY(' + ((i + 1) / stops.length) + ')';
stops.forEach(function (s) { s.removeAttribute('aria-current'); });
btn.setAttribute('aria-current', 'step');
desc.textContent = careerNotes[i];
});
});
06推薦の声
3枚を角度をずらして重ねる
推薦文のカード3枚が、少しずつ角度をずらして重なっています。

<main class="stage">
<section class="pq is-demo" aria-roledescription="carousel" aria-label="推薦の声">
<div class="pq__deck">
<blockquote class="pq__card" data-order="0">
<span class="pq__mark" aria-hidden="true">"</span>
<p class="pq__text">デザインハンドオフなしでも、意図がそのまま伝わりました。</p>
<p class="pq__who">パクOO・PM</p>
</blockquote>
<blockquote class="pq__card" data-order="1">
<span class="pq__mark" aria-hidden="true">"</span>
<p class="pq__text">予定より早く仕上がったのに、完成度はむしろ高かったです。</p>
<p class="pq__who">キムOO・代表</p>
</blockquote>
<blockquote class="pq__card" data-order="2">
<span class="pq__mark" aria-hidden="true">"</span>
<p class="pq__text">問い合わせから公開まで、コミュニケーションが滞ることはありませんでした。</p>
<p class="pq__who">イOO・デザイナー</p>
</blockquote>
</div>
<button class="pq__next" type="button">次のレビュー</button>
<p class="pq__status" role="status" aria-live="polite"></p>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-enter; // 카드 더미 순환 전환
$easing: $ease-spring;
$color: $subject-blue; // 인용부호·포인트 색
@include stage($stage-yellow);
.pq { width: min(340px, 92vw); text-align: center; }
.pq__deck { position: relative; height: 164px; }
.pq__card {
position: absolute;
inset: 0;
margin: 0;
box-sizing: border-box;
padding: $sp-4 $sp-3;
border-radius: $r-card;
background: rgba(255, 255, 255, .95);
transition: transform $duration $easing, opacity $duration $easing;
}
.pq__card[data-order="0"] { transform: translate(0, 0) rotate(0deg) scale(1); z-index: 3; opacity: 1; }
.pq__card[data-order="1"] { transform: translate($sp-2, $sp-2) rotate(-3deg) scale(.95); z-index: 2; opacity: .82; }
.pq__card[data-order="2"] { transform: translate($sp-4, $sp-3) rotate(3deg) scale(.9); z-index: 1; opacity: .55; }
.pq__mark {
display: block;
margin: 0 0 $sp-1;
font: 800 42px/1 Georgia, serif;
color: $color;
opacity: .32;
}
// ── 인용부호가 계속 흔들리는 장식(항상 재생, 117 실측: 40px·연한 회색은 면적 부족 → 42px·파랑으로 키움) ──────────────
.pq__card[data-order="0"] .pq__mark { animation: pq-wiggle 2s $easing infinite; }
@keyframes pq-wiggle {
0%, 100% { transform: rotate(0deg) scale(1); }
50% { transform: rotate(-12deg) scale(1.22); }
}
.pq__text { margin: 0 0 $sp-2; font-weight: 600; font-size: clamp(11px, 3.2vw, 12px); line-height: 1.5; font-family: inherit; color: $subject-ink; }
.pq__who { margin: 0; font-weight: 700; font-size: 10px; line-height: 1.3; font-family: inherit; color: rgba($subject-ink, .72); } // 흰 면 위 7.25:1
.pq__next {
margin-top: $sp-3;
padding: $sp-1 $sp-4;
border-radius: $r-pill;
border: 1px solid rgba(23, 20, 26, .18);
background: none;
color: rgba(23, 20, 26, .65);
font-weight: 700; font-size: 11px; line-height: 1.6; font-family: inherit;
cursor: pointer;
}
.pq__status { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); }
// ── 반응형(BD9) ──────────────────────────────────────────
@media (max-width: 340px), (max-height: 220px) {
.pq__deck { height: 128px; }
.pq__card { padding: $sp-2 $sp-2; }
.pq__mark { font-size: 20px; margin-bottom: $sp-1; }
}
// ── 접근성 ──────────────────────────────────────────
@media (prefers-reduced-motion: reduce) {
.pq__card { transition: none; }
.pq__card[data-order="0"] .pq__mark { 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);
}
.pq {
width: min(340px, 92vw);
text-align: center;
}
.pq__deck {
position: relative;
height: 164px;
}
.pq__card {
position: absolute;
inset: 0;
margin: 0;
box-sizing: border-box;
padding: 16px 12px;
border-radius: 16px;
background: rgba(255, 255, 255, 0.95);
transition: transform 600ms cubic-bezier(0.2, 0.8, 0.2, 1), opacity 600ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.pq__card[data-order="0"] {
transform: translate(0, 0) rotate(0deg) scale(1);
z-index: 3;
opacity: 1;
}
.pq__card[data-order="1"] {
transform: translate(8px, 8px) rotate(-3deg) scale(0.95);
z-index: 2;
opacity: 0.82;
}
.pq__card[data-order="2"] {
transform: translate(16px, 12px) rotate(3deg) scale(0.9);
z-index: 1;
opacity: 0.55;
}
.pq__mark {
display: block;
margin: 0 0 4px;
font: 800 42px/1 Georgia, serif;
color: #2f6df6;
opacity: 0.32;
}
.pq__card[data-order="0"] .pq__mark {
animation: pq-wiggle 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes pq-wiggle {
0%, 100% {
transform: rotate(0deg) scale(1);
}
50% {
transform: rotate(-12deg) scale(1.22);
}
}
.pq__text {
margin: 0 0 8px;
font-weight: 600;
font-size: clamp(11px, 3.2vw, 12px);
line-height: 1.5;
font-family: inherit;
color: #17141a;
}
.pq__who {
margin: 0;
font-weight: 700;
font-size: 10px;
line-height: 1.3;
font-family: inherit;
color: rgba(23, 20, 26, 0.72);
}
.pq__next {
margin-top: 12px;
padding: 4px 16px;
border-radius: 999px;
border: 1px solid rgba(23, 20, 26, 0.18);
background: none;
color: rgba(23, 20, 26, 0.65);
font-weight: 700;
font-size: 11px;
line-height: 1.6;
font-family: inherit;
cursor: pointer;
}
.pq__status {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
}
@media (max-width: 340px), (max-height: 220px) {
.pq__deck {
height: 128px;
}
.pq__card {
padding: 8px 8px;
}
.pq__mark {
font-size: 20px;
margin-bottom: 4px;
}
}
@media (prefers-reduced-motion: reduce) {
.pq__card {
transition: none;
}
.pq__card[data-order="0"] .pq__mark {
animation: none !important;
}
}// 실제 동작: "次のレビュー"를 누르면 카드 더미가 실제로 한 장씩 뒤로 돌아가며 순서가 바뀐다.
var root = document.querySelector('.pq');
var cards = Array.prototype.slice.call(root.querySelectorAll('.pq__card'));
var next = root.querySelector('.pq__next');
var status = root.querySelector('.pq__status');
function render() {
cards.forEach(function (card, i) { card.setAttribute('data-order', String(i)); });
status.textContent = cards[0].querySelector('.pq__who').textContent + ' のレビューを表示中';
}
next.addEventListener('click', function () {
root.classList.remove('is-demo');
cards.push(cards.shift());
render();
});import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./ProjectQuote.module.scss";
export type ProjectQuoteProps = { duration?: number; easing?: string; color?: string };
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const QUOTES = [
{ text: "디자인 핸드오프 없이도 구현 의도가 그대로 전달됐습니다.", who: "박OO · PM" },
{ text: "일정보다 빠르게 나왔는데 완성도는 오히려 더 높았습니다.", who: "김OO · 대표" },
{ text: "문의부터 배포까지 커뮤니케이션이 막힌 적이 없었습니다.", who: "이OO · 디자이너" },
];
export default function ProjectQuote({ duration = 600, easing = "cubic-bezier(.2,.8,.2,1)", color = "#2f6df6" }: ProjectQuoteProps) {
const [order, setOrder] = useState([0, 1, 2]);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
// 실제 동작: "다음 후기"를 누르면 카드 더미가 실제로 한 장씩 뒤로 돌아가며 순서가 바뀐다.
function next() {
setOrder((o) => [...o.slice(1), o[0]]);
}
return (
<section className={styles.pq} style={vars} aria-roledescription="carousel" aria-label="추천사">
<div className={styles.deck}>
{QUOTES.map((q, i) => (
<blockquote key={q.who} className={styles.card} data-order={order.indexOf(i)}>
<span className={styles.mark} aria-hidden="true">"</span>
<p className={styles.text}>{q.text}</p>
<p className={styles.who}>{q.who}</p>
</blockquote>
))}
</div>
<button type="button" className={styles.next} onClick={next}>다음 후기</button>
<p className={styles.status} role="status" aria-live="polite">{QUOTES[order[0]].who} 후기를 보는 중</p>
</section>
);
}06 · 推薦の声 — 計測 累積面積 0.188% · 動いたフレーム 16/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
一番手前が一番奥へ
「次の推薦文」をクリックすると一番手前のカードが実際に一番奥へ回り込み、束の並び順そのものが入れ替わります。
function render() {
cards.forEach(function (card, i) { card.setAttribute('data-order', String(i)); });
status.textContent = cards[0].querySelector('.pq__who').textContent + ' のレビューを表示中';
}
next.addEventListener('click', function () {
root.classList.remove('is-demo');
cards.push(cards.shift());
render();
});
07連絡フォーム
送信前に値を検査する
名前・メールアドレスを入力して送信すると、実際に値を検査します。

<main class="stage">
<form class="cf" novalidate>
<h2 class="cf__title">お問い合わせ</h2>
<label class="cf__field">
<span class="cf__label-text">お名前</span>
<input class="cf__input" name="name" type="text" placeholder="山田太郎">
</label>
<label class="cf__field">
<span class="cf__label-text">メール</span>
<input class="cf__input" name="email" type="email" placeholder="[email protected]">
</label>
<button class="cf__submit" type="submit">
<span class="cf__submit-label">メッセージを送る</span>
<svg class="cf__check" viewBox="0 0 20 20" aria-hidden="true"><polyline points="4,10 8,14 16,5"/></svg>
</button>
<p class="cf__status" role="status" aria-live="polite"></p>
</form>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-base; // 제출 버튼 전환
$easing: $ease-pop;
$color: $subject-blue; // 제출 버튼·체크 색
@include stage($stage-orange);
.cf {
width: min(320px, 92vw);
border-radius: $r-card;
padding: $sp-4;
box-sizing: border-box;
background: rgba(255, 255, 255, .95);
}
.cf__title { margin: 0 0 $sp-3; font-weight: 800; font-size: clamp(13px, 3.8vw, 15px); line-height: 1.2; font-family: inherit; color: $subject-ink; }
.cf__field { display: block; margin: 0 0 $sp-2; }
.cf__label-text { display: block; margin: 0 0 4px; font-weight: 700; font-size: 9px; line-height: 1.3; font-family: inherit; color: rgba($subject-ink, .72); } // 크림 면 위 7.08:1
.cf__input {
width: 100%;
box-sizing: border-box;
padding: $sp-1 $sp-2;
border-radius: $r-sm;
border: 1px solid rgba(23, 20, 26, .18);
font-weight: 500; font-size: 11px; line-height: 1.6; font-family: inherit;
color: $subject-ink;
}
.cf__input.is-shake { animation: cf-shake $dur-quick $easing; }
@keyframes cf-shake {
0%, 100% { transform: translateX(0); }
25% { transform: translateX(-6px); }
75% { transform: translateX(6px); }
}
.cf__submit {
position: relative;
display: inline-flex; align-items: center; gap: $sp-1;
margin-top: $sp-1;
padding: $sp-1 $sp-4;
border: 0;
border-radius: $r-pill;
background: $subject-blue-deep;
color: $subject-cream;
font-weight: 700; font-size: 11px; line-height: 1.6; font-family: inherit;
cursor: pointer;
transition: background $duration $easing;
animation: cf-glow 2s $easing infinite;
}
// ── 제출 버튼 글로우가 계속 반복되는 장식(항상 재생) ──────────────
@keyframes cf-glow {
0%, 100% { box-shadow: 0 0 0 0 rgba(47, 109, 246, .45); }
50% { box-shadow: 0 0 0 6px rgba(47, 109, 246, 0); }
}
.cf.is-done .cf__submit { animation: none; }
.cf__check {
width: 12px; height: 12px;
fill: none;
stroke: currentColor;
stroke-width: 2.4;
stroke-linecap: round;
stroke-linejoin: round;
stroke-dasharray: 18;
stroke-dashoffset: 18;
transition: stroke-dashoffset $duration $easing;
}
.cf.is-done .cf__submit { background: #51a474; }
.cf.is-done .cf__check { stroke-dashoffset: 0; }
.cf__status { min-height: 14px; margin: $sp-1 0 0; font-weight: 600; font-size: 9px; line-height: 1.4; font-family: inherit; color: rgba($subject-ink, .72); } // 스캔 땐 빈 칸이라 안 잡혔지만 제출 뒤 드러나는 같은 실패(3.97)라 같이 올렸다
// ── 반응형(BD9) ──────────────────────────────────────────
@media (max-width: 340px), (max-height: 220px) {
.cf { padding: $sp-3; }
.cf__title { margin-bottom: $sp-2; }
.cf__field { margin-bottom: $sp-1; }
.cf__label-text { margin-bottom: 0; }
.cf__input { padding: $sp-1 $sp-2; line-height: 1.3; }
.cf__submit { padding: $sp-1 $sp-3; line-height: 1.3; margin-top: 0; }
.cf__status { min-height: 12px; margin-top: 0; }
}
// ── 접근성 ──────────────────────────────────────────
@media (prefers-reduced-motion: reduce) {
.cf__input.is-shake { animation: none !important; }
.cf__submit { animation: none !important; transition: none; }
.cf__check { 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);
}
.cf {
width: min(320px, 92vw);
border-radius: 16px;
padding: 16px;
box-sizing: border-box;
background: rgba(255, 255, 255, 0.95);
}
.cf__title {
margin: 0 0 12px;
font-weight: 800;
font-size: clamp(13px, 3.8vw, 15px);
line-height: 1.2;
font-family: inherit;
color: #17141a;
}
.cf__field {
display: block;
margin: 0 0 8px;
}
.cf__label-text {
display: block;
margin: 0 0 4px;
font-weight: 700;
font-size: 9px;
line-height: 1.3;
font-family: inherit;
color: rgba(23, 20, 26, 0.72);
}
.cf__input {
width: 100%;
box-sizing: border-box;
padding: 4px 8px;
border-radius: 8px;
border: 1px solid rgba(23, 20, 26, 0.18);
font-weight: 500;
font-size: 11px;
line-height: 1.6;
font-family: inherit;
color: #17141a;
}
.cf__input.is-shake {
animation: cf-shake 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
@keyframes cf-shake {
0%, 100% {
transform: translateX(0);
}
25% {
transform: translateX(-6px);
}
75% {
transform: translateX(6px);
}
}
.cf__submit {
position: relative;
display: inline-flex;
align-items: center;
gap: 4px;
margin-top: 4px;
padding: 4px 16px;
border: 0;
border-radius: 999px;
background: #0a4fe8;
color: #fff7e6;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
font-family: inherit;
cursor: pointer;
transition: background 300ms cubic-bezier(0.2, 1.4, 0.4, 1);
animation: cf-glow 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
@keyframes cf-glow {
0%, 100% {
box-shadow: 0 0 0 0 rgba(47, 109, 246, 0.45);
}
50% {
box-shadow: 0 0 0 6px rgba(47, 109, 246, 0);
}
}
.cf.is-done .cf__submit {
animation: none;
}
.cf__check {
width: 12px;
height: 12px;
fill: none;
stroke: currentColor;
stroke-width: 2.4;
stroke-linecap: round;
stroke-linejoin: round;
stroke-dasharray: 18;
stroke-dashoffset: 18;
transition: stroke-dashoffset 300ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.cf.is-done .cf__submit {
background: #51a474;
}
.cf.is-done .cf__check {
stroke-dashoffset: 0;
}
.cf__status {
min-height: 14px;
margin: 4px 0 0;
font-weight: 600;
font-size: 9px;
line-height: 1.4;
font-family: inherit;
color: rgba(23, 20, 26, 0.72);
}
@media (max-width: 340px), (max-height: 220px) {
.cf {
padding: 12px;
}
.cf__title {
margin-bottom: 8px;
}
.cf__field {
margin-bottom: 4px;
}
.cf__label-text {
margin-bottom: 0;
}
.cf__input {
padding: 4px 8px;
line-height: 1.3;
}
.cf__submit {
padding: 4px 12px;
line-height: 1.3;
margin-top: 0;
}
.cf__status {
min-height: 12px;
margin-top: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.cf__input.is-shake {
animation: none !important;
}
.cf__submit {
animation: none !important;
transition: none;
}
.cf__check {
transition: none;
}
}// 실제 동작: 제출 시 お名前·이메일을 실제로 검사한다. 빠지면 그 칸이 흔들리며 포커스를 받고, 통과하면 체크가 그려진다.
var form = document.querySelector('.cf');
var name = form.querySelector('[name="name"]');
var email = form.querySelector('[name="email"]');
var status = form.querySelector('.cf__status');
function shakeField(el) {
el.classList.remove('is-shake');
void el.offsetWidth;
el.classList.add('is-shake');
}
form.addEventListener('submit', function (e) {
e.preventDefault();
var hasName = name.value.trim().length > 0;
var isEmailValid = /^\S+@\S+\.\S+$/.test(email.value);
if (!hasName) {
shakeField(name);
name.focus({ preventScroll: true });
status.textContent = 'お名前を入力してください。';
return;
}
if (!isEmailValid) {
shakeField(email);
email.focus({ preventScroll: true });
status.textContent = '正しいメールアドレスを入力してください。';
return;
}
form.classList.add('is-done');
status.textContent = 'メッセージを送信しました。';
});import { useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./ProjectContactForm.module.scss";
export type ProjectContactFormProps = { duration?: number; easing?: string; color?: string };
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function ProjectContactForm({ duration = 300, easing = "cubic-bezier(.2,1.4,.4,1)", color = "#0a4fe8" }: ProjectContactFormProps) {
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [done, setDone] = useState(false);
const [status, setStatus] = useState("");
const [shakeField, setShakeField] = useState<"name" | "email" | null>(null);
const nameRef = useRef<HTMLInputElement>(null);
const emailRef = useRef<HTMLInputElement>(null);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
// 실제 동작: 제출 시 이름·이메일을 실제로 검사한다. 빠지면 그 칸이 흔들리며 포커스를 받는다({ preventScroll: true } — 홈 목록 iframe 안에서 강제 스크롤을 막는다).
function onSubmit(e: any) {
e.preventDefault();
if (!name.trim()) {
setShakeField("name");
nameRef.current?.focus({ preventScroll: true });
setStatus("이름을 입력해 주세요.");
return;
}
if (!/^\S+@\S+\.\S+$/.test(email)) {
setShakeField("email");
emailRef.current?.focus({ preventScroll: true });
setStatus("올바른 이메일을 입력해 주세요.");
return;
}
setDone(true);
setStatus("메시지를 보냈습니다.");
}
return (
<form className={styles.cf} style={vars} noValidate onSubmit={onSubmit}>
<h2 className={styles.title}>연락하기</h2>
<label className={styles.field}>
<span className={styles.labelText}>이름</span>
<input
ref={nameRef}
className={`${styles.input} ${shakeField === "name" ? styles.shake : ""}`}
type="text"
placeholder="홍길동"
value={name}
onChange={(e: any) => setName(e.target.value)}
onAnimationEnd={() => setShakeField(null)}
/>
</label>
<label className={styles.field}>
<span className={styles.labelText}>이메일</span>
<input
ref={emailRef}
className={`${styles.input} ${shakeField === "email" ? styles.shake : ""}`}
type="email"
placeholder="[email protected]"
value={email}
onChange={(e: any) => setEmail(e.target.value)}
onAnimationEnd={() => setShakeField(null)}
/>
</label>
<button type="submit" className={`${styles.submit} ${done ? styles.done : ""}`}>
<span>메시지 보내기</span>
<svg className={styles.check} viewBox="0 0 20 20" aria-hidden="true"><polyline points="4,10 8,14 16,5" /></svg>
</button>
<p className={styles.status} role="status" aria-live="polite">{status}</p>
</form>
);
}07 · 連絡フォーム — 計測 累積面積 0.917% · 動いたフレーム 7/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
空欄が知らせる方法
空欄のままの項目は短く左右に揺れながらフォーカスを受け取り、両方通ると送信ボタンにチェックマークが描かれます。
function shakeField(el) {
el.classList.remove('is-shake');
void el.offsetWidth;
el.classList.add('is-shake');
}
form.addEventListener('submit', function (e) {
e.preventDefault();
var hasName = name.value.trim().length > 0;
var isEmailValid = /^\S+@\S+\.\S+$/.test(email.value);
if (!hasName) {
shakeField(name);
name.focus({ preventScroll: true });
status.textContent = 'お名前を入力してください。';
return;
}
if (!isEmailValid) {
shakeField(email);
email.focus({ preventScroll: true });
status.textContent = '正しいメールアドレスを入力してください。';
return;
}
form.classList.add('is-done');
status.textContent = 'メッセージを送信しました。';
});
08履歴書CTA
進行バーが満タンになると
「PDFをダウンロード」を押すと、ボタン内の進行バーが実際に満タンまで伸びたあと矢印アイコンがチェックマークに切り替わり、しばらくすると元の見た目に戻ります。

<main class="stage">
<section class="rc">
<span class="rc__accent" aria-hidden="true"></span>
<p class="rc__eyebrow">RESUME</p>
<h2 class="rc__title">履歴書PDFを受け取る</h2>
<button class="rc__btn" type="button">
<span class="rc__fill" aria-hidden="true"></span>
<span class="rc__ic-wrap" aria-hidden="true">
<svg class="rc__ic-arrow" viewBox="0 0 20 20"><polyline points="10,3 10,13" /><polyline points="5,9 10,14 15,9" /></svg>
<svg class="rc__ic-check" viewBox="0 0 20 20"><polyline points="4,10 8,14 16,5" /></svg>
</span>
<span class="rc__label">PDFをダウンロード</span>
</button>
<p class="rc__status" role="status" aria-live="polite"></p>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: 900ms; // 진행바 채워지는 시간(rc__btn.is-loading)
$easing: $ease-out;
$color: $subject-blue; // 버튼·진행바 색
@include stage($stage-paper);
.rc { width: min(300px, 92vw); text-align: center; }
// ── 상단 악센트 바가 계속 스캔하는 장식(항상 재생) ──────────────
.rc__accent {
display: block;
width: $sp-8; height: 3px;
margin: 0 auto $sp-2;
border-radius: $r-xs;
background: $color;
transform-origin: center;
animation: rc-scan 2s $ease-spring infinite;
}
@keyframes rc-scan {
0%, 70%, 100% { transform: scaleX(.3); opacity: .5; }
30% { transform: scaleX(1); opacity: 1; }
}
.rc__eyebrow { margin: 0 0 $sp-1; font-weight: 700; font-size: 10px; line-height: 1; font-family: inherit; letter-spacing: .14em; color: rgba($subject-ink, .72); } // 흰 면 위 7.26:1
.rc__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; }
.rc__btn {
position: relative;
overflow: hidden;
display: inline-flex; align-items: center; gap: $sp-1;
padding: $sp-2 $sp-5;
border: 0;
border-radius: $r-control;
background: $subject-blue-deep;
color: $subject-cream;
font-weight: 700; font-size: 12px; line-height: 1.4; font-family: inherit;
cursor: pointer;
isolation: isolate;
}
.rc__fill {
position: absolute; inset: 0;
background: rgba(255, 255, 255, .22);
transform: scaleX(0);
transform-origin: left; // 규칙8: 버튼 왼쪽 채움 오버레이
z-index: -1;
}
.rc__btn.is-loading .rc__fill { transform: scaleX(1); transition: transform $duration $easing; }
.rc__ic-wrap { position: relative; width: 14px; height: 14px; }
.rc__ic-arrow, .rc__ic-check {
position: absolute; inset: 0;
fill: none;
stroke: currentColor;
stroke-width: 2.2;
stroke-linecap: round;
stroke-linejoin: round;
transition: opacity $dur-quick $ease-pop;
}
.rc__ic-arrow {
opacity: 1;
animation: rc-bob 2s ease-in-out infinite;
}
.rc__ic-check {
opacity: 0;
stroke-dasharray: 18;
stroke-dashoffset: 18;
}
.rc__btn.is-done .rc__ic-arrow { opacity: 0; animation: none; }
.rc__btn.is-done .rc__ic-check { opacity: 1; stroke-dashoffset: 0; transition: stroke-dashoffset $dur-base $ease-pop, opacity $dur-quick $ease-pop; }
// ── 다운로드 화살표가 계속 살짝 튀는 장식(항상 재생) ──────────────
@keyframes rc-bob {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(3px); }
}
.rc__btn.is-loading .rc__ic-arrow { animation-play-state: paused; }
.rc__label { position: relative; }
.rc__btn.is-done .rc__label::after { content: " 완료"; }
.rc__status { min-height: 14px; margin: $sp-2 0 0; font-weight: 600; font-size: 9px; line-height: 1.4; font-family: inherit; color: rgba($subject-ink, .72); } // 클릭 뒤에만 드러나는 글자 — 3.97 이라 같이 올렸다
// ── 반응형(BD9) ──────────────────────────────────────────
@media (max-width: 340px), (max-height: 220px) {
.rc__eyebrow { display: none; }
.rc__title { margin-bottom: $sp-2; font-size: 13px; }
}
// ── 접근성 ──────────────────────────────────────────
@media (prefers-reduced-motion: reduce) {
.rc__accent { animation: none !important; }
.rc__ic-arrow { animation: none !important; }
.rc__fill, .rc__ic-check { transition: none; }
}@charset "UTF-8";
html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.rc {
width: min(300px, 92vw);
text-align: center;
}
.rc__accent {
display: block;
width: 32px;
height: 3px;
margin: 0 auto 8px;
border-radius: 4px;
background: #2f6df6;
transform-origin: center;
animation: rc-scan 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes rc-scan {
0%, 70%, 100% {
transform: scaleX(0.3);
opacity: 0.5;
}
30% {
transform: scaleX(1);
opacity: 1;
}
}
.rc__eyebrow {
margin: 0 0 4px;
font-weight: 700;
font-size: 10px;
line-height: 1;
font-family: inherit;
letter-spacing: 0.14em;
color: rgba(23, 20, 26, 0.72);
}
.rc__title {
margin: 0 0 12px;
font-weight: 800;
font-size: clamp(14px, 4vw, 16px);
line-height: 1.2;
font-family: inherit;
color: #17141a;
}
.rc__btn {
position: relative;
overflow: hidden;
display: inline-flex;
align-items: center;
gap: 4px;
padding: 8px 20px;
border: 0;
border-radius: 12px;
background: #0a4fe8;
color: #fff7e6;
font-weight: 700;
font-size: 12px;
line-height: 1.4;
font-family: inherit;
cursor: pointer;
isolation: isolate;
}
.rc__fill {
position: absolute;
inset: 0;
background: rgba(255, 255, 255, 0.22);
transform: scaleX(0);
transform-origin: left;
z-index: -1;
}
.rc__btn.is-loading .rc__fill {
transform: scaleX(1);
transition: transform 900ms ease-out;
}
.rc__ic-wrap {
position: relative;
width: 14px;
height: 14px;
}
.rc__ic-arrow, .rc__ic-check {
position: absolute;
inset: 0;
fill: none;
stroke: currentColor;
stroke-width: 2.2;
stroke-linecap: round;
stroke-linejoin: round;
transition: opacity 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.rc__ic-arrow {
opacity: 1;
animation: rc-bob 2s ease-in-out infinite;
}
.rc__ic-check {
opacity: 0;
stroke-dasharray: 18;
stroke-dashoffset: 18;
}
.rc__btn.is-done .rc__ic-arrow {
opacity: 0;
animation: none;
}
.rc__btn.is-done .rc__ic-check {
opacity: 1;
stroke-dashoffset: 0;
transition: stroke-dashoffset 300ms cubic-bezier(0.2, 1.4, 0.4, 1), opacity 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
@keyframes rc-bob {
0%, 100% {
transform: translateY(0);
}
50% {
transform: translateY(3px);
}
}
.rc__btn.is-loading .rc__ic-arrow {
animation-play-state: paused;
}
.rc__label {
position: relative;
}
.rc__btn.is-done .rc__label::after {
content: " 완료";
}
.rc__status {
min-height: 14px;
margin: 8px 0 0;
font-weight: 600;
font-size: 9px;
line-height: 1.4;
font-family: inherit;
color: rgba(23, 20, 26, 0.72);
}
@media (max-width: 340px), (max-height: 220px) {
.rc__eyebrow {
display: none;
}
.rc__title {
margin-bottom: 8px;
font-size: 13px;
}
}
@media (prefers-reduced-motion: reduce) {
.rc__accent {
animation: none !important;
}
.rc__ic-arrow {
animation: none !important;
}
.rc__fill, .rc__ic-check {
transition: none;
}
}// 실제 동작: 클릭하면 진행바가 실제로 채워진 뒤 체크 아이콘으로 바뀌고, 잠시 뒤 원래 상태로 돌아간다.
var btn = document.querySelector('.rc__btn');
var status = document.querySelector('.rc__status');
var isBusy = false;
btn.addEventListener('click', function () {
if (isBusy) return;
isBusy = true;
btn.classList.add('is-loading');
status.textContent = '準備中';
setTimeout(function () {
btn.classList.remove('is-loading');
btn.classList.add('is-done');
status.textContent = 'ダウンロード完了';
setTimeout(function () {
btn.classList.remove('is-done');
status.textContent = '';
isBusy = false;
}, 1600);
}, 900);
});import { useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./ProjectResumeCta.module.scss";
export type ProjectResumeCtaProps = { duration?: number; easing?: string; color?: string };
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Status = "idle" | "loading" | "done";
export default function ProjectResumeCta({ duration = 900, easing = "ease-out", color = "#2f6df6" }: ProjectResumeCtaProps) {
const [status, setStatus] = useState<Status>("idle");
const busy = useRef(false);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
// 실제 동작: 클릭하면 진행바가 실제로 채워진 뒤 체크 아이콘으로 바뀌고, 잠시 뒤 원래 상태로 되돌아간다.
function onClick() {
if (busy.current) return;
busy.current = true;
setStatus("loading");
window.setTimeout(() => {
setStatus("done");
window.setTimeout(() => {
setStatus("idle");
busy.current = false;
}, 1600);
}, duration);
}
return (
<section className={styles.rc} style={vars}>
<span className={styles.accent} aria-hidden="true" />
<p className={styles.eyebrow}>RESUME</p>
<h2 className={styles.title}>이력서 PDF 받기</h2>
<button type="button" className={`${styles.btn} ${status === "loading" ? styles.loading : ""} ${status === "done" ? styles.done : ""}`} onClick={onClick}>
<span className={styles.fill} aria-hidden="true" />
<span className={styles.icWrap} aria-hidden="true">
<svg className={styles.icArrow} viewBox="0 0 20 20"><polyline points="10,3 10,13" /><polyline points="5,9 10,14 15,9" /></svg>
<svg className={styles.icCheck} viewBox="0 0 20 20"><polyline points="4,10 8,14 16,5" /></svg>
</span>
<span className={styles.label}>PDF 내려받기{status === "done" ? " 완료" : ""}</span>
</button>
<p className={styles.status} role="status" aria-live="polite">
{status === "loading" ? "준비 중" : status === "done" ? "다운로드 완료" : ""}
</p>
</section>
);
}08 · 履歴書CTA — 計測 累積面積 0.111% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
押す前は矢印が跳ねる
押す前は矢印だけが小さく上下に跳ねています。
btn.addEventListener('click', function () {
if (isBusy) return;
isBusy = true;
btn.classList.add('is-loading');
status.textContent = '準備中';
setTimeout(function () {
btn.classList.remove('is-loading');
btn.classList.add('is-done');
status.textContent = 'ダウンロード完了';
setTimeout(function () {
btn.classList.remove('is-done');
status.textContent = '';
isBusy = false;
}, 1600);
}, 900);
});
09ショーケースフッター
正規表現で書式を判定
メール欄にアドレスを入れて購読を押すと、正規表現で実際に書式を判定します。正しければチェックマークが描かれて完了文が表示され、誤っていれば入力欄が短く左右に震えます。

<main class="stage">
<footer class="pf2">
<div class="pf2__top">
<p class="pf2__heading">新しいプロジェクトの情報をメールで受け取る</p>
<form class="pf2__form" novalidate>
<input class="pf2__input" type="email" placeholder="[email protected]" aria-label="メール">
<button class="pf2__submit" type="submit">
<span class="pf2__submit-label">登録する</span>
<svg class="pf2__check" viewBox="0 0 20 20" aria-hidden="true"><polyline points="4,10 8,14 16,5"/></svg>
</button>
</form>
<p class="pf2__status" role="status" aria-live="polite"></p>
</div>
<div class="pf2__cols">
<div class="pf2__col"><p class="pf2__col-title">ショーケース</p><a href="#">プロジェクト</a><a href="#">履歴書</a></div>
<div class="pf2__col"><p class="pf2__col-title">連絡先</p><a href="#">メール</a><a href="#">お問い合わせフォーム</a></div>
<div class="pf2__col"><p class="pf2__col-title">フォロー</p><a href="#">GitHub</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);
.pf2 { width: min(420px, 95vw); }
.pf2__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; }
.pf2__form { display: flex; gap: $sp-1; }
.pf2__input {
flex: 1 1 auto;
box-sizing: border-box;
padding: $sp-1 $sp-2;
border-radius: $r-sm;
border: 1px solid rgba(255, 247, 230, .28);
background: rgba(255, 247, 230, .06);
color: $subject-cream;
font-weight: 500; font-size: 11px; line-height: 1.6; font-family: inherit;
}
.pf2__input.is-shake { animation: pf2-shake $dur-quick $easing; }
@keyframes pf2-shake {
0%, 100% { transform: translateX(0); }
25% { transform: translateX(-6px); }
75% { transform: translateX(6px); }
}
.pf2__submit {
position: relative;
flex: 0 0 auto;
display: inline-flex; align-items: center; gap: 4px;
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-sm;
background: $subject-blue-deep;
color: $subject-cream;
font-weight: 700; font-size: 11px; line-height: 1.6; font-family: inherit;
cursor: pointer;
animation: pf2-glow 2s $easing infinite;
}
@keyframes pf2-glow {
0%, 100% { box-shadow: 0 0 0 0 rgba(47, 109, 246, .45); }
50% { box-shadow: 0 0 0 6px rgba(47, 109, 246, 0); }
}
.pf2__check {
width: 11px; height: 11px;
fill: none;
stroke: currentColor;
stroke-width: 2.4;
stroke-linecap: round;
stroke-linejoin: round;
stroke-dasharray: 18;
stroke-dashoffset: 18;
transition: stroke-dashoffset $duration $easing;
}
.pf2__form.is-done .pf2__check { stroke-dashoffset: 0; }
.pf2__status { min-height: 14px; margin: $sp-1 0 0; font-weight: 600; font-size: 9px; line-height: 1.4; font-family: inherit; color: rgba(255, 247, 230, .6); }
.pf2__cols { display: flex; gap: $sp-5; margin-top: $sp-4; flex-wrap: wrap; }
.pf2__col { display: flex; flex-direction: column; gap: 4px; }
.pf2__col-title { margin: 0 0 $sp-1; font-weight: 700; font-size: 9px; line-height: 1.3; font-family: inherit; letter-spacing: .08em; color: rgba($subject-cream, .72); } // 잉크 면 위 9.18:1
.pf2__col a { font-weight: 500; font-size: 10px; line-height: 1.6; font-family: inherit; color: rgba(255, 247, 230, .75); text-decoration: none; }
// ── 반응형(BD9) ──────────────────────────────────────────
@media (max-width: 340px), (max-height: 220px) {
.pf2__cols { display: none; }
.pf2__heading { margin-bottom: $sp-1; }
}
// ── 접근성 ──────────────────────────────────────────
@media (prefers-reduced-motion: reduce) {
.pf2__input.is-shake { animation: none !important; }
.pf2__submit { animation: none !important; }
.pf2__check { 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);
}
.pf2 {
width: min(420px, 95vw);
}
.pf2__heading {
margin: 0 0 8px;
font-weight: 700;
font-size: clamp(11px, 3.2vw, 12px);
line-height: 1.4;
font-family: inherit;
color: #fff7e6;
}
.pf2__form {
display: flex;
gap: 4px;
}
.pf2__input {
flex: 1 1 auto;
box-sizing: border-box;
padding: 4px 8px;
border-radius: 8px;
border: 1px solid rgba(255, 247, 230, 0.28);
background: rgba(255, 247, 230, 0.06);
color: #fff7e6;
font-weight: 500;
font-size: 11px;
line-height: 1.6;
font-family: inherit;
}
.pf2__input.is-shake {
animation: pf2-shake 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
@keyframes pf2-shake {
0%, 100% {
transform: translateX(0);
}
25% {
transform: translateX(-6px);
}
75% {
transform: translateX(6px);
}
}
.pf2__submit {
position: relative;
flex: 0 0 auto;
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 12px;
border: 0;
border-radius: 8px;
background: #0a4fe8;
color: #fff7e6;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
font-family: inherit;
cursor: pointer;
animation: pf2-glow 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
@keyframes pf2-glow {
0%, 100% {
box-shadow: 0 0 0 0 rgba(47, 109, 246, 0.45);
}
50% {
box-shadow: 0 0 0 6px rgba(47, 109, 246, 0);
}
}
.pf2__check {
width: 11px;
height: 11px;
fill: none;
stroke: currentColor;
stroke-width: 2.4;
stroke-linecap: round;
stroke-linejoin: round;
stroke-dasharray: 18;
stroke-dashoffset: 18;
transition: stroke-dashoffset 300ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.pf2__form.is-done .pf2__check {
stroke-dashoffset: 0;
}
.pf2__status {
min-height: 14px;
margin: 4px 0 0;
font-weight: 600;
font-size: 9px;
line-height: 1.4;
font-family: inherit;
color: rgba(255, 247, 230, 0.6);
}
.pf2__cols {
display: flex;
gap: 20px;
margin-top: 16px;
flex-wrap: wrap;
}
.pf2__col {
display: flex;
flex-direction: column;
gap: 4px;
}
.pf2__col-title {
margin: 0 0 4px;
font-weight: 700;
font-size: 9px;
line-height: 1.3;
font-family: inherit;
letter-spacing: 0.08em;
color: rgba(255, 247, 230, 0.72);
}
.pf2__col a {
font-weight: 500;
font-size: 10px;
line-height: 1.6;
font-family: inherit;
color: rgba(255, 247, 230, 0.75);
text-decoration: none;
}
@media (max-width: 340px), (max-height: 220px) {
.pf2__cols {
display: none;
}
.pf2__heading {
margin-bottom: 4px;
}
}
@media (prefers-reduced-motion: reduce) {
.pf2__input.is-shake {
animation: none !important;
}
.pf2__submit {
animation: none !important;
}
.pf2__check {
transition: none;
}
}// 실제 동작: 제출 시 실제로 メール 형식을 검사한다. 통과하면 체크가 그려지고, 틀리면 입력칸이 흔들린다.
var form = document.querySelector('.pf2__form');
var input = document.querySelector('.pf2__input');
var status = document.querySelector('.pf2__status');
form.addEventListener('submit', function (e) {
e.preventDefault();
var ok = /^\S+@\S+\.\S+$/.test(input.value);
if (ok) {
form.classList.add('is-done');
status.textContent = '登録完了 — 新しいプロジェクトをメールでお届けします。';
} else {
input.classList.remove('is-shake');
void input.offsetWidth;
input.classList.add('is-shake');
status.textContent = '正しいメールアドレスを入力してください。';
}
});import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./ProjectFooter.module.scss";
export type ProjectFooterProps = { duration?: number; easing?: string; color?: string };
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function ProjectFooter({ duration = 300, easing = "cubic-bezier(.2,1.4,.4,1)", color = "#0a4fe8" }: ProjectFooterProps) {
const [email, setEmail] = useState("");
const [done, setDone] = useState(false);
const [shake, setShake] = useState(false);
const [status, setStatus] = useState("");
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
// 실제 동작: 제출 시 실제로 이메일 형식을 검사한다. 통과하면 체크가 그려지고, 틀리면 입력칸이 흔들린다.
function onSubmit(e: any) {
e.preventDefault();
if (/^\S+@\S+\.\S+$/.test(email)) {
setDone(true);
setStatus("구독 완료 — 새 프로젝트를 메일로 보내드립니다.");
} else {
setShake(false);
requestAnimationFrame(() => setShake(true));
setStatus("올바른 이메일을 입력해 주세요.");
}
}
return (
<footer className={styles.pf} style={vars}>
<div className={styles.top}>
<p className={styles.heading}>새 프로젝트 소식을 메일로 받아보세요</p>
<form className={`${styles.form} ${done ? styles.done : ""}`} noValidate onSubmit={onSubmit}>
<input
className={`${styles.input} ${shake ? styles.shake : ""}`}
type="email"
placeholder="[email protected]"
aria-label="이메일"
value={email}
onChange={(e: any) => setEmail(e.target.value)}
onAnimationEnd={() => setShake(false)}
/>
<button type="submit" className={styles.submit}>
<span>구독</span>
<svg className={styles.check} viewBox="0 0 20 20" aria-hidden="true"><polyline points="4,10 8,14 16,5" /></svg>
</button>
</form>
<p className={styles.status} role="status" aria-live="polite">{status}</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="#">깃허브</a><a href="#">링크드인</a></div>
</div>
</footer>
);
}09 · フッター — 計測 累積面積 0.583% · 動いたフレーム 7/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
下の3列は動かない
下にはショーケース・連絡・フォローの3列のリンクが並びます。
form.addEventListener('submit', function (e) {
e.preventDefault();
var ok = /^\S+@\S+\.\S+$/.test(input.value);
if (ok) {
form.classList.add('is-done');
status.textContent = '登録完了 — 新しいプロジェクトをメールでお届けします。';
} else {
input.classList.remove('is-shake');
void input.offsetWidth;
input.classList.add('is-shake');
status.textContent = '正しいメールアドレスを入力してください。';
}
});
どこで壊れるか — 落とし穴
4回つまずいた
9つを組み立てる途中、実際に4回つまずきました。
サムネイルがはみ出した
ひとつ目はプロジェクトグリッド(02)です。サムネイルの高さを44pxのままにしてタブ周りの余白も広く取っていたところ、カード2段とタブ行を足した縦の長さが480×300のキャンバスを超えて、レンダリングした画面にスクロールバーが出てしまいました。サムネイルを30pxまで縮め、タブの余白とグリッドの隙間を詰めることで1画面に収まりました。
薄い装飾は測れない
ふたつ目はケース詳細ブロック(03)です。最初は「結果を見る」ボタンの周りに細いリング状の縁を薄く点滅させる装飾を入れていましたが、フレーム間の変化量が小さすぎて、23枚中わずか4枚でしか動きとして拾われませんでした。カード上部に太めのアクセントバーを置いてscaleXで大きくスキャンさせる方式に切り替えると、17枚まで一気に増えました。色の差が薄い装飾は、実測値もそのぶん小さく出ます。
箱がカードより小さかった
みっつ目は推薦の声(06)です。カードの重なりを収める箱の高さを128pxに決めていましたが、実際のカード内容(大きな引用符・2行の文章・氏名・内側の余白)のほうが大きく、後ろのカードの文字が前のカードの枠をはみ出して「次の推薦文」ボタンと重なって見えていました。箱の高さを164pxに広げ、引用符のサイズを縮めたところ重なりは解消しました。
装飾がないと0になる
よっつ目は連絡フォーム(07)です。この項目は動きがすべて送信イベントに紐づいていたため、最初は常時再生する装飾が1つもなく、自動レンダリングの計測でアニメーションがまったく検出されませんでした。ほかの項目にならって送信ボタンにうっすらとしたグローの点滅を足したところ、ようやく動きが測定されました。上の4つを直したあとのコードは、pcbj2jd9で開くzipの中身とそのまま一致しています。
アクセシビリティ
装飾だけ止めて状態は残す
モーションを減らす設定(prefers-reduced-motion: reduce)のブラウザでは、9つとも装飾のための動きだけが止まり、そのときの状態は保たれます — 経歴の進捗線は伸びきったまま、プロジェクトグリッドは絞り込んだ状態のまま固定され、ケース詳細のカウントアップの数字は情報そのものなので値を消しません。
タブと現在地の伝え方
プロジェクトグリッド(02)はrole="tablist"とaria-selectedでタブの状態を伝え、経歴タイムライン(05)はaria-current="step"で今見ている地点を知らせます。このMDNのElement.getBoundingClientRect()解説には、プロジェクトグリッド(02)のインジケーターがなぜ決め打ちのpxではなく実測値で動くのかがまとめられています。
status とフォーカス移動
推薦の声(06)はaria-roledescription="carousel"と、結果を読み上げるrole="status"を組み合わせて使い、連絡フォーム(07)は氏名・メールアドレスが空のときfocus({ preventScroll: true })でその入力欄へフォーカスだけを移し、画面のスクロール位置はそのままにします。ランディングページ向けの他のセクションテンプレートはテンプレートカテゴリーに、このデモを作った人の情報は紹介ページにあります。
FAQ
この ポートフォリオ ギャラリー に実際の顔写真や作品スクリーンショットを使ってもいいですか?
もちろんです、むしろそれを前提に組んであります。ワードマーク・サムネイル・カード内の顔アイコンの位置はすべて図形のプレースホルダーなので、実物のファイルに差し替えても縦横比や余白はそのまま保たれます。図形に使っているのはdemo-tokens.scss内の$subject-ink・$subject-blue・$subject-creamという3色だけで、キャンバスの下地に敷いたイエローやオレンジは独立した別グループの変数なので、切り替えても人物・作品側の色には影響しません。
React版はCSS版と動きが違いますか?
いいえ、判定ロジックも見た目の変化も同じです。選択中のタブ・パネルの開閉・カードの並び順といった値はuseStateで保持し、変数3つ(duration・easing・color)はpropsで渡してCSS変数(--duration・--easing・--color)に橋渡しします。プロジェクトグリッド(02)のインジケーターだけはuseEffectの中でgetBoundingClientRectを呼び出し、マウント直後とクリックのたびに位置を測り直します。
連絡フォームや履歴書CTAは、実際にメールが届いたりPDFがダウンロードされたりしますか?
入力チェック・フォーカス移動・進行バー・チェックマークの描画は、どれも本物の状態変化です。ただしデモ自体には送信先のサーバーも実ファイルのPDFもつながっていないため、クリックしても本当にメールが送られたりダウンロードが始まったりはしません。実際に組み込むときは、連絡フォームの送信処理にAPI呼び出しを足し、履歴書CTAの完了タイミングに本物のhrefを差し込めば、検証や演出のロジックはそのまま流用できます。









