GODRICH

ポートフォリオ ギャラリー UI 9選 — ドラッグからモーフ切替まで

ポートフォリオ ギャラリーとは、写真や作品を格子状に並べ、ホバーやクリックで拡大・絞り込み・並び替え・切り替えが起こる画面のことです。Pinterestで保存したあのポートフォリオギャラリーを、9通りの方式に分けてそのままコードでお届けします。

自動再生 · タイルを押すとその項目へ · すべて1つのzip

9つは人気順ではなく、ギャラリーに出会う人が実際にたどる順番で並べました。最初は通り過ぎながら眺め(01)、手でめくって見て(02)、気に入った1枚を選んで拡大し(03)、動画なら再生ボタンを探し(04)、枚数が多ければタグで絞り込み(05)、持ち主なら自分で順番を整理し(06)、代表作を数枚さっと見渡し(07)、気になってキャプションを読み(08)、最後は1枚に没入して大きく見ます(09)。

番号 名前 何が動くか JS
01 無限スクロール写真ウォール トラック translateX なし
02 ポラロイドスタックギャラリー カード rotatetranslate なし
03 商品ギャラリー ホバーズーム 写真 scale、価格バー translateY なし
04 動画サムネイルグリッド 再生オーバーレイ ボタン scale、リング scaleopacity なし
05 タグ絞り込みポートフォリオグリッド カード opacityscale 13行(自動巡回のみ)
06 ドラッグで並び替える写真グリッド タイル translateX 34行
07 全幅スライドショー サムネイルストリップ トラック translateX、サムネイル枠 19行
08 スクリムキャプションリビールギャラリー スクリム opacity、キャプション translateY なし
09 グリッド→単一ビュー モーフ切替 タイル translatescale 24行

01無限スクロール写真ウォール

小さな正方形の写真が横に途切れず流れ続けます。帯をまるごと複製して後ろに継ぎ足しておくと、トラックが自分の幅のちょうど半分(-50%)動いた瞬間に画面が最初とまったく同じ見た目になるので、継ぎ目がわかりません。

translateX(-50%) 무한 루프track 통째 복제margin-right 간격
.wall-track {
  display: flex;
  width: max-content;              // 写真5枚 × 2セット = 10コマ
  animation: wall-loop $duration linear infinite;
}
.wall-tile {
  flex: 0 0 auto;
  margin-right: $sp-2;             // gap ではなく margin — でないと -50% がずれる
}
@keyframes wall-loop {
  0%   { transform: translateX(0); }
  100% { transform: translateX(-50%); }
}

02ポラロイドスタックギャラリー

写真4枚がポラロイドのようにわずかに角度をずらして重なり、一番上の1枚が横にずれて回転しながら退くと次の写真が現れる、という流れを順番に繰り返します。1枚ずつめくって見せたい旅行アルバムに向いています。

rotate() 각도 오프셋z-index 스택transform 순환 키프레임
.polaroid--top {
  transform: rotate(2deg) translate(1px, -4px);
  z-index: 4;
  animation: shuffle $duration $easing infinite;
}
@keyframes shuffle {
  0%, 10%, 55%, 100% { transform: rotate(2deg) translate(1px, -4px); opacity: 1; }
  30% { transform: rotate(16deg) translate(56px, -26px) scale(.94); opacity: .25; }
}

03商品ギャラリー ホバーズーム

商品写真のマスにカーソルを乗せると、枠のサイズはそのままで中の写真だけが拡大し、同時に下から価格バーがスライドで現れます。価格をさりげなく見せたいショップの商品一覧に使えます。

overflow:hidden 프레임내부 이미지만 scaletranslateY 가격바
.pcard { overflow: hidden; }        // 枠の大きさは変わらない
.pcard__img {
  transition: transform $duration $easing;
}
.pcard__price {
  transform: translateY(100%);      // 普段は下端に隠れている
  transition: transform $duration $easing;
}
.pcard:hover .pcard__img { transform: scale(1.14); }
.pcard:hover .pcard__price { transform: translateY(0); }

04動画サムネイルグリッド 再生オーバーレイ

動画サムネイルの各マスに中央の再生ボタンがあり、カーソルを乗せるとボタンが拡大しながら波紋リングが一度広がって、再生できる動画だと知らせます。動画を数本まとめて見せるメディアページに向いています。

재생 아이콘 scaleripple 확산 1회재생시간 배지
.vcard__ring {
  border: 2px solid $subject-cream;
  opacity: 0;
  animation: ring-pulse $duration $easing infinite;
}
@keyframes ring-pulse {
  0%, 15% { transform: scale(1); opacity: 0; }
  30%     { opacity: .9; }
  60%     { transform: scale(1.9); opacity: 0; }
  100%    { transform: scale(1); opacity: 0; }
}

05タグ絞り込みポートフォリオグリッド

カードごとにカテゴリータグが付いており、上部のタグチップをクリックすると該当しないカードがJavaScriptなしで:has()セレクタだけで薄く縮んで消えます。Web・アプリ・ブランディングを分けて見せるエージェンシーの紹介ページに合います。

:has(:checked) 필터체크박스 필터칩opacity+scale 감춤
.pf-frame:has(.pf-check:checked) .pf-card {
  opacity: .18;
  transform: scale(.92);
}
.pf-frame:has(input[data-cat="web"]:checked) .pf-card[data-cat="web"] {
  opacity: 1;
  transform: scale(1);
}

06ドラッグで並び替える写真グリッド

写真のマスを指やマウスで押して隣のマスへ引っ張ると2枚の位置が入れ替わり、離すと残りのマスも自然に整列し直します。代表写真を自分で選ぶ商品画像管理画面に使います。

pointerdown/move/uptransform 스왑키보드 화살표 이동
handle.addEventListener('pointerdown', e => {
  dragging = true; startX = e.clientX;
  handle.setPointerCapture(e.pointerId);
});
handle.addEventListener('pointermove', e => {
  if (!dragging) return;
  dx = e.clientX - startX;
  row.style.transform = `translateX(${dx}px)`;
});
handle.addEventListener('pointerup', () => {
  dragging = false;
  const target = dx > 30 ? row.nextElementSibling
    : dx < -30 ? row.previousElementSibling : null;
  row.style.transform = '';
  if (target) list.insertBefore(dx > 0 ? target : row, dx > 0 ? row : target);
  dx = 0;
});

07全幅スライドショー サムネイルストリップ

大きな写真1枚が画面幅いっぱいに表示されて自動で切り替わり、下のサムネイル列では今表示中の写真の枠だけが光ります。代表作数点を順番にめくるポートフォリオのヒーローに向いています。

1번 슬라이드 클론translateX 트랙썸네일 active 테두리
.ss-track {
  width: 500%;                       // 実写真4枚 + 1枚目のクローン1枚 = 5コマ
  animation: slide-loop $duration linear infinite;
}
@keyframes slide-loop {
  0%, 18%   { transform: translateX(0%); }
  20%, 38%  { transform: translateX(-20%); }
  40%, 58%  { transform: translateX(-40%); }
  60%, 78%  { transform: translateX(-60%); }
  80%, 100% { transform: translateX(-80%); }   // クローンの位置は1枚目と同じ絵
}

08スクリムキャプションリビールギャラリー

普段は写真だけが見え、カーソルを乗せると画像全体に暗いスクリムが広がるように現れ、中央にタイトルが浮かび上がります。端から帯が上がる方式と違って写真全体が暗くなるので、写真そのものが読み取りやすいままです。

radial-gradient 스크림opacity 페이드업translateY(6px) 텍스트
.cr-card__scrim {
  background: radial-gradient(120% 120% at 50% 65%, transparent 0%, rgba($stage-ink, .86) 100%);
  opacity: 0;
  transition: opacity $duration $easing;
}
.cr-card:hover .cr-card__scrim { opacity: 1; }
.cr-card:hover .cr-card__caption { opacity: 1; transform: translateY(0); }

09グリッド→単一ビュー モーフ切替

マスを1つクリックしてもモーダルは開かず、グリッド自体が組み替わり、選んだマスがその場で拡大し残りのマスはその場で薄く縮みます。詳細パネルを持たない、写真そのもので見せるポートフォリオに使います。

:checked 상태 분기transform: translate+scalez-index 승격
.g-grid:has(.g-radio:checked) .g-tile:not(:has(.g-radio:checked)) {
  opacity: .22;
  transform: scale(.88);
}
// マスごとの格子内の位置が固定なので、拡大するときの移動量もマスごとに固定値になる
.g-grid .g-tile:nth-child(1):has(.g-radio:checked) {
  transform: translate(64px, 32px) scale(3.29, 2.14);
}

どこで壊れるか — 落とし穴1つ

05(タグ絞り込み)と07(サムネイルストリップ)を初めてスマホ幅(320px)でレンダリングしたとき、縦方向に内容がはみ出してマスが切れました。どちらも min(240px, 82vw) のように横幅だけをビューポート基準で縮めていたため、横幅は縮んでも行数(2行)とカードの比率(4:3)はそのままで、実際の縦の高さがスマホ画面の200px予算を大きく超えていたのです。zipのパスワード bhpgj3rm で開く vanilla フォルダの中で、05はカードを2行から1行4マスに広げ縦長の3:4比率に変え、07は全体の幅をさらに200pxまで縮めてようやく収まりました。横幅しか見ない min()vw のレスポンシブは、縦に長いレイアウトでは別途高さを確認する必要があるということです。

アクセシビリティ

モーション低減設定(prefers-reduced-motion: reduce)をオンにした画面では、9項目すべてトランジションとループだけが止まり、最終状態はそのまま残るようにしています。01はトラックが止まって最初のコマのまま表示され、02・04・08は巡回アニメーションなしで既定の位置に固定され、03・06・09は拡大・移動が一瞬で適用され、07はスライドがトランジションなしでその場で切り替わります。06の写真タイルはポインターでのドラッグだけでなく左右矢印キーでも順番を変えられ、09のマスもネイティブのラジオボタンなので、Tabキーと矢印キーだけで写真を選んで大きく見られます。ポインターイベントの仕様はMDN Pointer Eventsで、:has() セレクタの対応状況はMDN :has()で確認できます。同じgallery系統の他の2編、CSS フォト ギャラリー 9選ビフォーアフター スライダー UI 9選もあわせてご覧ください。

FAQ

05の:has()フィルターはすべてのブラウザで動きますか。

Chrome・Edge・Safariは以前から対応しており、Firefoxもバージョン121から対応しています。セレクタに対応していない古いブラウザでは単に無視されるだけで、フィルターだけが効かなくなりカードは全部表示されたままなので、レイアウトが崩れることはありません。

09で写真が元の比率と違って伸びて見えるのはなぜですか。

マスが拡大するとき、縦横で違う倍率のscale(3.29, 2.14)をかけているためです。正方形のマスを格子全体の横に長い領域まで広げるにはこの比率差が必要で、実際の写真を使うときは.g-tile__imgobject-fit: coverを付ければ、マスの伸びに関係なく写真自体は元の比率を保てます。

06のドラッグはタッチスクリーンでも使えますか。

使えます。pointerdownpointermovepointerupはマウス・タッチ・ペンを1つのAPIでまとめて扱うので指でのドラッグも同じように動作し、各タイルにtouch-action: noneを指定してドラッグ中に画面ごとスクロールしてしまうのも防いでいます。

解凍パスワードを入力してください

パスワードはこの記事の本文の中にあります。読み進めると出てきます。