ポートフォリオサイト テンプレート9選 — プロジェクト紹介セクション
ポートフォリオサイト テンプレートは、紹介ヒーロー・プロジェクトグリッド・ケース詳細・経歴タイムライン・連絡フォームなど、個人のポートフォリオページに使うセクションをまとめて配るコード集です。
自動再生 · タイルを押すとその項目へ · すべて1つのzip
9つは、採用担当者やクライアントがポートフォリオサイトを開いてから読み進める順番に並べました。まず何者かを伝え(紹介ヒーロー)、手がけた仕事を分類して見せ(プロジェクトグリッド)、代表的な1件を数字つきで深掘りし(ケース詳細ブロック)、扱える技術を並べ(技術スタック)、歩んできた経歴を示し(経歴タイムライン)、第三者の言葉で裏付けを足し(推薦の声)、連絡先を渡し(連絡フォーム)、履歴書を持ち帰らせ(履歴書CTA)、最後に他のリンクへ導きます(フッター)。顔写真や作品カットが入る場所に置いたのは実物ではなく、$subject-ink・$subject-blue・$subject-creamの3色だけで組んだ図形です。差し込むファイルを入れ替えるだけで、周りの余白や整列はそのまま使えます。
01紹介ヒーロー
背景でぼかした円3つが、それぞれ違う軌道で絶えず漂い続けます。その手前に図形のワードマーク・見出し・サブ見出し・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・アプリ・デザイン)をクリックすると、黒いインジケーターがそのタブの実際の幅・位置まで移動し、選ばれていない分類のカードは実際に薄くなります。インジケーターの位置は決め打ちのpxではなく、クリックのたびにgetBoundingClientRectで測り直した値です。
function 인디케이터이동(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');
인디케이터이동(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から目標値まで上がっていきます。カード上部のアクセントバーはクリックの有無にかかわらず、常にスキャンする動きを繰り返します。
function 카운트업() {
if (재생됨) return;
재생됨 = true;
var 시작 = performance.now();
var 길이 = 900;
function easeOut(t) { return 1 - Math.pow(1 - t, 3); }
function tick(now) {
var t = Math.min(1, (now - 시작) / 길이);
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個(実物のロゴではありません)を並べたトラックが、継ぎ目を感じさせずに流れ続けます。カーソルを乗せるか横のボタンを押している間だけ、その場で流れが止まります。
// トラックそのものには 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で実際に伸びて塗りつぶされ、上の説明文がその時点の内容に切り替わります。
var 설명 = [
'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 = 설명[i];
});
});
06推薦の声
推薦文のカード3枚が、少しずつ角度をずらして重なっています。「次の推薦文」をクリックすると一番手前のカードが実際に一番奥へ回り込み、束の並び順そのものが入れ替わります。
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連絡フォーム
名前・メールアドレスを入力して送信すると、実際に値を検査します。空欄のままの項目は短く左右に揺れながらフォーカスを受け取り、両方通ると送信ボタンにチェックマークが描かれます。
function 흔들기(el) {
el.classList.remove('is-shake');
void el.offsetWidth;
el.classList.add('is-shake');
}
form.addEventListener('submit', function (e) {
e.preventDefault();
var 이름있음 = name.value.trim().length > 0;
var 이메일유효 = /^\S+@\S+\.\S+$/.test(email.value);
if (!이름있음) {
흔들기(name);
name.focus({ preventScroll: true });
status.textContent = '이름을 입력해 주세요.';
return;
}
if (!이메일유효) {
흔들기(email);
email.focus({ preventScroll: true });
status.textContent = '올바른 이메일을 입력해 주세요.';
return;
}
form.classList.add('is-done');
status.textContent = '메시지를 보냈습니다.';
});
08履歴書CTA
「PDFをダウンロード」を押すと、ボタン内の進行バーが実際に満タンまで伸びたあと矢印アイコンがチェックマークに切り替わり、しばらくすると元の見た目に戻ります。押す前は矢印だけが小さく上下に跳ねています。
btn.addEventListener('click', function () {
if (진행중) return;
진행중 = 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 = '';
진행중 = false;
}, 1600);
}, 900);
});
09ショーケースフッター
メール欄にアドレスを入れて購読を押すと、正規表現で実際に書式を判定します。正しければチェックマークが描かれて完了文が表示され、誤っていれば入力欄が短く左右に震えます。下にはショーケース・連絡・フォローの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 = '올바른 이메일을 입력해 주세요.';
}
});
どこで壊れるか — 落とし穴
9つを組み立てる途中、実際に4回つまずきました。ひとつ目はプロジェクトグリッド(02)です。サムネイルの高さを44pxのままにしてタブ周りの余白も広く取っていたところ、カード2段とタブ行を足した縦の長さが480×300のキャンバスを超えて、レンダリングした画面にスクロールバーが出てしまいました。サムネイルを30pxまで縮め、タブの余白とグリッドの隙間を詰めることで1画面に収まりました。
ふたつ目はケース詳細ブロック(03)です。最初は「結果を見る」ボタンの周りに細いリング状の縁を薄く点滅させる装飾を入れていましたが、フレーム間の変化量が小さすぎて、23枚中わずか4枚でしか動きとして拾われませんでした。カード上部に太めのアクセントバーを置いてscaleXで大きくスキャンさせる方式に切り替えると、17枚まで一気に増えました。色の差が薄い装飾は、実測値もそのぶん小さく出ます。
みっつ目は推薦の声(06)です。カードの重なりを収める箱の高さを128pxに決めていましたが、実際のカード内容(大きな引用符・2行の文章・氏名・内側の余白)のほうが大きく、後ろのカードの文字が前のカードの枠をはみ出して「次の推薦文」ボタンと重なって見えていました。箱の高さを164pxに広げ、引用符のサイズを縮めたところ重なりは解消しました。
よっつ目は連絡フォーム(07)です。この項目は動きがすべて送信イベントに紐づいていたため、最初は常時再生する装飾が1つもなく、自動レンダリングの計測でアニメーションがまったく検出されませんでした。ほかの項目にならって送信ボタンにうっすらとしたグローの点滅を足したところ、ようやく動きが測定されました。上の4つを直したあとのコードは、pcbj2jd9で開くzipの中身とそのまま一致しています。
アクセシビリティ
モーションを減らす設定(prefers-reduced-motion: reduce)のブラウザでは、9つとも装飾のための動きだけが止まり、そのときの状態は保たれます — 経歴の進捗線は伸びきったまま、プロジェクトグリッドは絞り込んだ状態のまま固定され、ケース詳細のカウントアップの数字は情報そのものなので値を消しません。プロジェクトグリッド(02)はrole="tablist"とaria-selectedでタブの状態を伝え、経歴タイムライン(05)はaria-current="step"で今見ている地点を知らせます。推薦の声(06)はaria-roledescription="carousel"と、結果を読み上げるrole="status"を組み合わせて使い、連絡フォーム(07)は氏名・メールアドレスが空のときfocus({ preventScroll: true })でその入力欄へフォーカスだけを移し、画面のスクロール位置はそのままにします。このMDNのElement.getBoundingClientRect()解説には、プロジェクトグリッド(02)のインジケーターがなぜ決め打ちのpxではなく実測値で動くのかがまとめられています。
ランディングページ向けの他のセクションテンプレートはテンプレートカテゴリーに、このデモを作った人の情報は紹介ページにあります。
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を差し込めば、検証や演出のロジックはそのまま流用できます。