ポートフォリオ ギャラリー UI 9選 — ドラッグからモーフ切替まで
ポートフォリオ ギャラリーとは、写真や作品を格子状に並べ、ホバーやクリックで拡大・絞り込み・並び替え・切り替えが起こる画面のことです。Pinterestで保存したあのポートフォリオギャラリーを、9通りの方式に分けてそのままコードでお届けします。
01 gallery-infinite-scroll-photo-wall
02 gallery-polaroid-stack
03 gallery-hover-zoom-product
05 gallery-tag-filter-portfolio-grid
06 gallery-drag-reorder-photo-grid
08 gallery-caption-reveal-hover-scrim
09 gallery-grid-to-single-view-morph
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 無限スクロール写真ウォール
- 02 ポラロイドスタックギャラリー
- 03 商品ギャラリー ホバーズーム
- 04 動画サムネイルグリッド 再生オーバーレイ
- 05 タグ絞り込みポートフォリオグリッド
- 06 ドラッグで並び替える写真グリッド
- 07 全幅スライドショー サムネイルストリップ
- 08 スクリムキャプションリビールギャラリー
- 09 グリッド→単一ビュー モーフ切替
9つは人気順ではなく、ギャラリーに出会う人が実際にたどる順番で並べました。css フォト ギャラリー レスポンシブとして最初は通り過ぎながら眺め(01)、手でめくって見て(02)、気に入った1枚を選んで拡大し(03)、動画なら再生ボタンを探し(04)、枚数が多ければタグで絞り込み(05)、持ち主なら自分で順番を整理し(06)、代表作を数枚さっと見渡し(07)、気になってキャプションを読み(08)、最後は1枚に没入して大きく見ます(09)。2枚を重ねてつまみで仕切る側がビフォーアフター スライダー UI 9選、クリックなしでホイールとレンズで寄る側が画像 拡大 表示 UI 9選です。
| 番号 | 名前 | 何が動くか | JS |
|---|---|---|---|
| 01 | 無限スクロール写真ウォール | トラック translateX |
なし |
| 02 | ポラロイドスタックギャラリー | カード rotate・translate |
なし |
| 03 | 商品ギャラリー ホバーズーム | 写真 scale、価格バー translateY |
なし |
| 04 | 動画サムネイルグリッド 再生オーバーレイ | ボタン scale、リング scale・opacity |
なし |
| 05 | タグ絞り込みポートフォリオグリッド | カード opacity・scale |
13行(自動巡回のみ) |
| 06 | ドラッグで並び替える写真グリッド | タイル translateX |
34行 |
| 07 | 全幅スライドショー サムネイルストリップ | トラック translateX、サムネイル枠 |
19行 |
| 08 | スクリムキャプションリビールギャラリー | スクリム opacity、キャプション translateY |
なし |
| 09 | グリッド→単一ビュー モーフ切替 | タイル translate・scale |
24行 |
01無限スクロール写真ウォール
ギャラリー レイアウトとして何が見えるか
小さな正方形の写真が横に途切れず流れ続けます。

<main class="stage">
<div class="wall-viewport" aria-label="終わりなく流れる写真ギャラリー">
<ul class="wall-track is-demo">
<li class="wall-tile wall-tile--pink"></li>
<li class="wall-tile wall-tile--mint"></li>
<li class="wall-tile wall-tile--lilac"></li>
<li class="wall-tile wall-tile--sky"></li>
<li class="wall-tile wall-tile--pink2"></li>
<li class="wall-tile wall-tile--pink" aria-hidden="true"></li>
<li class="wall-tile wall-tile--mint" aria-hidden="true"></li>
<li class="wall-tile wall-tile--lilac" aria-hidden="true"></li>
<li class="wall-tile wall-tile--sky" aria-hidden="true"></li>
<li class="wall-tile wall-tile--pink2" aria-hidden="true"></li>
</ul>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-loop; // 한 바퀴 도는 시간
$easing: linear;
$color: $subject-pink; // 대표 타일 색
@include stage($stage-paper);
.wall-viewport {
width: min(280px, 84vw);
overflow: hidden;
border-radius: $r-card;
box-shadow: $shadow-press;
}
.wall-track {
display: flex;
list-style: none;
margin: 0;
padding: 0;
width: max-content;
}
// 간격은 margin-right 로만 준다 (BD15) — flex gap 을 쓰면 -50% 가 gap/2 만큼 모자라 튄다
.wall-tile {
flex: 0 0 auto;
width: 56px;
height: 42px;
margin-right: $sp-2;
border-radius: $r-sm;
}
.wall-tile--pink { background: linear-gradient(155deg, $color 0%, $subject-cream 100%); }
.wall-tile--mint { background: linear-gradient(155deg, $subject-mint 0%, $subject-cream 100%); }
.wall-tile--lilac { background: linear-gradient(155deg, $subject-lilac 0%, $subject-cream 100%); }
.wall-tile--sky { background: linear-gradient(155deg, $subject-sky 0%, $subject-cream 100%); }
.wall-tile--pink2 { background: linear-gradient(205deg, $subject-cream 0%, $color 100%); }
// 트랙을 통째로 복제(위 10칸 = 5칸 × 2벌)해 두면 -50% 지점이 처음과 픽셀 단위로 같아 이어붙인 자리가 안 보인다
.wall-track.is-demo { animation: wall-loop $duration $easing infinite; }
@keyframes wall-loop {
0% { transform: translateX(0); }
100% { transform: translateX(-50%); }
}
@media (prefers-reduced-motion: reduce) {
.wall-track.is-demo { animation: none; }
}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);
}
.wall-viewport {
width: min(280px, 84vw);
overflow: hidden;
border-radius: 16px;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
.wall-track {
display: flex;
list-style: none;
margin: 0;
padding: 0;
width: max-content;
}
.wall-tile {
flex: 0 0 auto;
width: 56px;
height: 42px;
margin-right: 8px;
border-radius: 8px;
}
.wall-tile--pink {
background: linear-gradient(155deg, #ff9ebb 0%, #fff7e6 100%);
}
.wall-tile--mint {
background: linear-gradient(155deg, #4cd4a6 0%, #fff7e6 100%);
}
.wall-tile--lilac {
background: linear-gradient(155deg, #b9a0ff 0%, #fff7e6 100%);
}
.wall-tile--sky {
background: linear-gradient(155deg, #6ec8ff 0%, #fff7e6 100%);
}
.wall-tile--pink2 {
background: linear-gradient(205deg, #fff7e6 0%, #ff9ebb 100%);
}
.wall-track.is-demo {
animation: wall-loop 2s linear infinite;
}
@keyframes wall-loop {
0% {
transform: translateX(0);
}
100% {
transform: translateX(-50%);
}
}
@media (prefers-reduced-motion: reduce) {
.wall-track.is-demo {
animation: none;
}
}import type { CSSProperties } from "react";
import styles from "./InfiniteScrollPhotoWall.module.scss";
export type InfiniteScrollPhotoWallProps = {
/** 한 바퀴 도는 시간 (기본 2000ms) */
duration?: number;
easing?: string;
/** 대표 타일 색 */
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const ORDER = ["a", "b", "c", "d", "e", "a", "b", "c", "d", "e"] as const;
/** 작은 사진들이 가로로 끝없이 흐르고, 트랙을 통째로 복제해 이어붙인 자리가 안 보인다 */
export default function InfiniteScrollPhotoWall({
duration = 2000,
easing = "linear",
color = "#ff9ebb",
}: InfiniteScrollPhotoWallProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<div className={styles.viewport} style={vars} aria-label="A gallery of photos scrolling endlessly">
<ul className={styles.track}>
{ORDER.map((tone, i) => (
<li key={i} className={`${styles.tile} ${styles["tile-" + tone]}`} aria-hidden={i >= 5} />
))}
</ul>
</div>
);
}01 · 無限スクロール写真ウォール — 計測 累積面積 8.037% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードのどこを見るか
帯をまるごと複製して後ろに継ぎ足しておくと、トラックが自分の幅のちょうど半分(-50%)動いた瞬間に画面が最初とまったく同じ見た目になるので、継ぎ目がわかりません。
.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枚が横にずれて回転しながら退くと次の写真が現れる、という流れを順番に繰り返します。

<main class="stage">
<div class="stack is-demo" aria-label="めくって見るポラロイド写真スタック" tabindex="0">
<div class="polaroid polaroid--1"><span class="polaroid__photo polaroid__photo--a"></span></div>
<div class="polaroid polaroid--2"><span class="polaroid__photo polaroid__photo--b"></span></div>
<div class="polaroid polaroid--3"><span class="polaroid__photo polaroid__photo--c"></span></div>
<div class="polaroid polaroid--4"><span class="polaroid__photo polaroid__photo--d"></span></div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-loop; // 한 장이 물러났다 돌아오는 주기
$easing: $ease-spring;
$color: $subject-lilac; // 폴라로이드 사진 색 포인트
@include stage($stage-paper);
.stack {
position: relative;
width: 128px;
height: 148px;
outline: none;
}
.polaroid {
position: absolute;
inset: 0;
padding: $sp-2 $sp-2 $sp-6;
background: $subject-cream;
border-radius: $r-xs;
box-shadow: $shadow-press;
transform-origin: 50% 85%; // 규칙8: 폴라로이드 회전 피벗 아래축
}
.polaroid__photo {
display: block;
width: 100%;
height: 100%;
border-radius: $r-xs;
}
.polaroid__photo--a { background: linear-gradient(160deg, $subject-pink 0%, $color 100%); }
.polaroid__photo--b { background: linear-gradient(160deg, $subject-mint 0%, $color 100%); }
.polaroid__photo--c { background: linear-gradient(160deg, $subject-sky 0%, $color 100%); }
.polaroid__photo--d { background: linear-gradient(160deg, $color 0%, $subject-pink 100%); }
.polaroid--1 { transform: rotate(-8deg) translate(-6px, 4px); z-index: 1; }
.polaroid--2 { transform: rotate(5deg) translate(4px, 1px); z-index: 2; }
.polaroid--3 { transform: rotate(-3deg) translate(-2px, -2px); z-index: 3; }
.polaroid--4 { transform: rotate(2deg) translate(1px, -4px); z-index: 4; }
// 네 장이 차례로 살짝씩 밀려났다 제자리로 돌아온다 — 항상 한 장은 움직이는 중이라 스택이 계속 넘겨지는 것처럼 보인다
.stack.is-demo .polaroid--1 { animation: shuffle-1 $duration $easing infinite; }
.stack.is-demo .polaroid--2 { animation: shuffle-2 $duration $easing infinite; animation-delay: -0.5s; }
.stack.is-demo .polaroid--3 { animation: shuffle-3 $duration $easing infinite; animation-delay: -1s; }
.stack.is-demo .polaroid--4 { animation: shuffle-4 $duration $easing infinite; animation-delay: -1.5s; }
@keyframes shuffle-1 {
0%, 10%, 55%, 100% { transform: rotate(-8deg) translate(-6px, 4px); opacity: 1; }
30% { transform: rotate(-24deg) translate(-64px, -22px) scale(.94); opacity: .25; }
}
@keyframes shuffle-2 {
0%, 10%, 55%, 100% { transform: rotate(5deg) translate(4px, 1px); opacity: 1; }
30% { transform: rotate(20deg) translate(60px, -20px) scale(.94); opacity: .25; }
}
@keyframes shuffle-3 {
0%, 10%, 55%, 100% { transform: rotate(-3deg) translate(-2px, -2px); opacity: 1; }
30% { transform: rotate(-18deg) translate(-58px, -24px) scale(.94); opacity: .25; }
}
@keyframes shuffle-4 {
0%, 10%, 55%, 100% { transform: rotate(2deg) translate(1px, -4px); opacity: 1; }
30% { transform: rotate(16deg) translate(56px, -26px) scale(.94); opacity: .25; }
}
@media (prefers-reduced-motion: reduce) {
.stack.is-demo .polaroid--1,
.stack.is-demo .polaroid--2,
.stack.is-demo .polaroid--3,
.stack.is-demo .polaroid--4 { animation: none; }
}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);
}
.stack {
position: relative;
width: 128px;
height: 148px;
outline: none;
}
.polaroid {
position: absolute;
inset: 0;
padding: 8px 8px 24px;
background: #fff7e6;
border-radius: 4px;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transform-origin: 50% 85%;
}
.polaroid__photo {
display: block;
width: 100%;
height: 100%;
border-radius: 4px;
}
.polaroid__photo--a {
background: linear-gradient(160deg, #ff9ebb 0%, #b9a0ff 100%);
}
.polaroid__photo--b {
background: linear-gradient(160deg, #4cd4a6 0%, #b9a0ff 100%);
}
.polaroid__photo--c {
background: linear-gradient(160deg, #6ec8ff 0%, #b9a0ff 100%);
}
.polaroid__photo--d {
background: linear-gradient(160deg, #b9a0ff 0%, #ff9ebb 100%);
}
.polaroid--1 {
transform: rotate(-8deg) translate(-6px, 4px);
z-index: 1;
}
.polaroid--2 {
transform: rotate(5deg) translate(4px, 1px);
z-index: 2;
}
.polaroid--3 {
transform: rotate(-3deg) translate(-2px, -2px);
z-index: 3;
}
.polaroid--4 {
transform: rotate(2deg) translate(1px, -4px);
z-index: 4;
}
.stack.is-demo .polaroid--1 {
animation: shuffle-1 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.stack.is-demo .polaroid--2 {
animation: shuffle-2 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
animation-delay: -0.5s;
}
.stack.is-demo .polaroid--3 {
animation: shuffle-3 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
animation-delay: -1s;
}
.stack.is-demo .polaroid--4 {
animation: shuffle-4 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
animation-delay: -1.5s;
}
@keyframes shuffle-1 {
0%, 10%, 55%, 100% {
transform: rotate(-8deg) translate(-6px, 4px);
opacity: 1;
}
30% {
transform: rotate(-24deg) translate(-64px, -22px) scale(0.94);
opacity: 0.25;
}
}
@keyframes shuffle-2 {
0%, 10%, 55%, 100% {
transform: rotate(5deg) translate(4px, 1px);
opacity: 1;
}
30% {
transform: rotate(20deg) translate(60px, -20px) scale(0.94);
opacity: 0.25;
}
}
@keyframes shuffle-3 {
0%, 10%, 55%, 100% {
transform: rotate(-3deg) translate(-2px, -2px);
opacity: 1;
}
30% {
transform: rotate(-18deg) translate(-58px, -24px) scale(0.94);
opacity: 0.25;
}
}
@keyframes shuffle-4 {
0%, 10%, 55%, 100% {
transform: rotate(2deg) translate(1px, -4px);
opacity: 1;
}
30% {
transform: rotate(16deg) translate(56px, -26px) scale(0.94);
opacity: 0.25;
}
}
@media (prefers-reduced-motion: reduce) {
.stack.is-demo .polaroid--1,
.stack.is-demo .polaroid--2,
.stack.is-demo .polaroid--3,
.stack.is-demo .polaroid--4 {
animation: none;
}
}import type { CSSProperties } from "react";
import styles from "./PolaroidStack.module.scss";
export type PolaroidStackProps = {
/** 한 장이 물러났다 돌아오는 주기 (기본 2000ms) */
duration?: number;
easing?: string;
/** 사진 색 포인트 */
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
/** 네 장이 차례로 살짝씩 밀려났다 제자리로 돌아와, 스택이 계속 넘겨지는 것처럼 보인다 */
export default function PolaroidStack({
duration = 2000,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#b9a0ff",
}: PolaroidStackProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<div className={styles.stack} style={vars} tabIndex={0} aria-label="A stack of polaroid photos you flip through">
<div className={`${styles.polaroid} ${styles.p1}`}><span className={`${styles.photo} ${styles.photoA}`} /></div>
<div className={`${styles.polaroid} ${styles.p2}`}><span className={`${styles.photo} ${styles.photoB}`} /></div>
<div className={`${styles.polaroid} ${styles.p3}`}><span className={`${styles.photo} ${styles.photoC}`} /></div>
<div className={`${styles.polaroid} ${styles.p4}`}><span className={`${styles.photo} ${styles.photoD}`} /></div>
</div>
);
}02 · ポラロイドスタックギャラリー — 計測 累積面積 29.485% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードの読み方
1枚ずつめくって見せたい旅行アルバムに向いています。
.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商品ギャラリー ホバーズーム
どんな動きか
商品写真のマスにカーソルを乗せると、枠のサイズはそのままで中の写真だけが拡大し、同時に下から価格バーがスライドで現れます。

<main class="stage">
<ul class="pgallery is-demo" aria-label="商品ギャラリー">
<li class="pcard" tabindex="0"><span class="pcard__img pcard__img--a"></span><span class="pcard__price">₩29,000</span></li>
<li class="pcard" tabindex="0"><span class="pcard__img pcard__img--b"></span><span class="pcard__price">₩18,000</span></li>
<li class="pcard" tabindex="0"><span class="pcard__img pcard__img--c"></span><span class="pcard__price">₩42,000</span></li>
<li class="pcard" tabindex="0"><span class="pcard__img pcard__img--d"></span><span class="pcard__price">₩12,000</span></li>
</ul>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base; // 확대·가격바 애니메이션 시간
$easing: $ease-out;
$color: $subject-blue; // 사진 그러데이션 포인트
@include stage($stage-ink);
.pgallery {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: $sp-2;
list-style: none;
margin: 0;
padding: 0;
width: 184px;
max-width: 100%;
}
.pcard {
position: relative;
aspect-ratio: 4 / 3;
overflow: hidden;
border-radius: $r-sm;
outline: none;
cursor: pointer;
}
.pcard__img {
position: absolute;
inset: -8%;
transition: transform $duration $easing;
}
.pcard__img--a { background: linear-gradient(155deg, $color 0%, $stage-ink 100%); }
.pcard__img--b { background: linear-gradient(205deg, $subject-cream 0%, $color 100%); }
.pcard__img--c { background: linear-gradient(115deg, $color 0%, $subject-cream 60%); }
.pcard__img--d { background: linear-gradient(250deg, $stage-ink 0%, $color 100%); }
.pcard__price {
position: absolute;
left: 0; right: 0; bottom: 0;
padding: $sp-1 $sp-2;
background: rgba(23, 20, 26, .84);
color: $subject-cream;
font: 700 11px/1.4 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-family: inherit;
text-align: center;
transform: translateY(100%);
transition: transform $duration $easing;
}
.pcard:hover .pcard__img, .pcard:focus-visible .pcard__img { transform: scale(1.14); }
.pcard:hover .pcard__price, .pcard:focus-visible .pcard__price { transform: translateY(0); }
.pgallery.is-demo .pcard__img,
.pgallery.is-demo .pcard__price { animation-duration: $duration; animation-timing-function: $easing; animation-iteration-count: infinite; }
.pgallery.is-demo .pcard:nth-child(1) .pcard__img { animation-name: zoom-loop; }
.pgallery.is-demo .pcard:nth-child(1) .pcard__price { animation-name: price-loop; }
.pgallery.is-demo .pcard:nth-child(2) .pcard__img { animation-name: zoom-loop; animation-delay: -0.5s; }
.pgallery.is-demo .pcard:nth-child(2) .pcard__price { animation-name: price-loop; animation-delay: -0.5s; }
.pgallery.is-demo .pcard:nth-child(3) .pcard__img { animation-name: zoom-loop; animation-delay: -1s; }
.pgallery.is-demo .pcard:nth-child(3) .pcard__price { animation-name: price-loop; animation-delay: -1s; }
.pgallery.is-demo .pcard:nth-child(4) .pcard__img { animation-name: zoom-loop; animation-delay: -1.5s; }
.pgallery.is-demo .pcard:nth-child(4) .pcard__price { animation-name: price-loop; animation-delay: -1.5s; }
@keyframes zoom-loop {
0%, 10% { transform: scale(1); }
25%, 35% { transform: scale(1.14); }
50%, 100% { transform: scale(1); }
}
@keyframes price-loop {
0%, 10% { transform: translateY(100%); }
25%, 35% { transform: translateY(0); }
50%, 100% { transform: translateY(100%); }
}
@media (hover: none) {
.pcard:active .pcard__img { transform: scale(1.14); }
.pcard:active .pcard__price { transform: translateY(0); }
}
@media (prefers-reduced-motion: reduce) {
.pcard__img, .pcard__price { transition: none; }
.pgallery.is-demo .pcard__img, .pgallery.is-demo .pcard__price { animation: 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);
}
.pgallery {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8px;
list-style: none;
margin: 0;
padding: 0;
width: 184px;
max-width: 100%;
}
.pcard {
position: relative;
aspect-ratio: 4/3;
overflow: hidden;
border-radius: 8px;
outline: none;
cursor: pointer;
}
.pcard__img {
position: absolute;
inset: -8%;
transition: transform 300ms ease-out;
}
.pcard__img--a {
background: linear-gradient(155deg, #2f6df6 0%, #17141a 100%);
}
.pcard__img--b {
background: linear-gradient(205deg, #fff7e6 0%, #2f6df6 100%);
}
.pcard__img--c {
background: linear-gradient(115deg, #2f6df6 0%, #fff7e6 60%);
}
.pcard__img--d {
background: linear-gradient(250deg, #17141a 0%, #2f6df6 100%);
}
.pcard__price {
position: absolute;
left: 0;
right: 0;
bottom: 0;
padding: 4px 8px;
background: rgba(23, 20, 26, 0.84);
color: #fff7e6;
font: 700 11px/1.4 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-family: inherit;
text-align: center;
transform: translateY(100%);
transition: transform 300ms ease-out;
}
.pcard:hover .pcard__img, .pcard:focus-visible .pcard__img {
transform: scale(1.14);
}
.pcard:hover .pcard__price, .pcard:focus-visible .pcard__price {
transform: translateY(0);
}
.pgallery.is-demo .pcard__img,
.pgallery.is-demo .pcard__price {
animation-duration: 300ms;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
}
.pgallery.is-demo .pcard:nth-child(1) .pcard__img {
animation-name: zoom-loop;
}
.pgallery.is-demo .pcard:nth-child(1) .pcard__price {
animation-name: price-loop;
}
.pgallery.is-demo .pcard:nth-child(2) .pcard__img {
animation-name: zoom-loop;
animation-delay: -0.5s;
}
.pgallery.is-demo .pcard:nth-child(2) .pcard__price {
animation-name: price-loop;
animation-delay: -0.5s;
}
.pgallery.is-demo .pcard:nth-child(3) .pcard__img {
animation-name: zoom-loop;
animation-delay: -1s;
}
.pgallery.is-demo .pcard:nth-child(3) .pcard__price {
animation-name: price-loop;
animation-delay: -1s;
}
.pgallery.is-demo .pcard:nth-child(4) .pcard__img {
animation-name: zoom-loop;
animation-delay: -1.5s;
}
.pgallery.is-demo .pcard:nth-child(4) .pcard__price {
animation-name: price-loop;
animation-delay: -1.5s;
}
@keyframes zoom-loop {
0%, 10% {
transform: scale(1);
}
25%, 35% {
transform: scale(1.14);
}
50%, 100% {
transform: scale(1);
}
}
@keyframes price-loop {
0%, 10% {
transform: translateY(100%);
}
25%, 35% {
transform: translateY(0);
}
50%, 100% {
transform: translateY(100%);
}
}
@media (hover: none) {
.pcard:active .pcard__img {
transform: scale(1.14);
}
.pcard:active .pcard__price {
transform: translateY(0);
}
}
@media (prefers-reduced-motion: reduce) {
.pcard__img, .pcard__price {
transition: none;
}
.pgallery.is-demo .pcard__img, .pgallery.is-demo .pcard__price {
animation: none;
}
}import type { CSSProperties } from "react";
import styles from "./HoverZoomProductGallery.module.scss";
export type HoverZoomProductGalleryProps = {
duration?: number;
easing?: string;
/** 사진 그러데이션 포인트 색 */
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const PRODUCTS = [
{ tone: "a", price: "$29" },
{ tone: "b", price: "$18" },
{ tone: "c", price: "$42" },
{ tone: "d", price: "$12" },
];
/** 커서를 올리면 액자는 그대로인 채 안쪽 사진만 확대되고, 아래에서 가격표가 슬라이드로 올라온다 */
export default function HoverZoomProductGallery({
duration = 300,
easing = "ease-out",
color = "#2f6df6",
}: HoverZoomProductGalleryProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<ul className={styles.gallery} style={vars} aria-label="Product gallery">
{PRODUCTS.map((p, i) => (
<li key={i} className={styles.card} tabIndex={0}>
<span className={`${styles.img} ${styles["img-" + p.tone]}`} />
<span className={styles.price}>{p.price}</span>
</li>
))}
</ul>
);
}03 · 商品ギャラリー ホバーズーム — 計測 累積面積 5.825% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
どこを変えるか
価格をさりげなく見せたいショップの商品一覧に使えます。
.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動画サムネイルグリッド 再生オーバーレイ
何が見えるか
動画サムネイルの各マスに中央の再生ボタンがあり、カーソルを乗せるとボタンが拡大しながら波紋リングが一度広がって、再生できる動画だと知らせます。
<main class="stage">
<ul class="vgallery is-demo" aria-label="動画ギャラリー">
<li class="vcard">
<span class="vcard__thumb vcard__thumb--a"></span>
<span class="vcard__ring" aria-hidden="true"></span>
<button class="vcard__play" type="button" aria-label="動画を再生">▶</button>
<span class="vcard__time">0:32</span>
</li>
<li class="vcard">
<span class="vcard__thumb vcard__thumb--b"></span>
<span class="vcard__ring" aria-hidden="true"></span>
<button class="vcard__play" type="button" aria-label="動画を再生">▶</button>
<span class="vcard__time">1:05</span>
</li>
<li class="vcard">
<span class="vcard__thumb vcard__thumb--c"></span>
<span class="vcard__ring" aria-hidden="true"></span>
<button class="vcard__play" type="button" aria-label="動画を再生">▶</button>
<span class="vcard__time">0:48</span>
</li>
</ul>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-loop; // 재생버튼·파동 한 주기
$easing: $ease-out;
$color: $subject-sky; // 썸네일 그러데이션 포인트
@include stage($stage-ink);
.vgallery {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: $sp-2;
list-style: none;
margin: 0;
padding: 0;
width: min(280px, 84vw);
}
.vcard {
position: relative;
aspect-ratio: 4 / 3;
overflow: hidden;
border-radius: $r-sm;
}
.vcard__thumb { position: absolute; inset: 0; }
.vcard__thumb--a { background: linear-gradient(155deg, $color 0%, $stage-ink 100%); }
.vcard__thumb--b { background: linear-gradient(205deg, $subject-lilac 0%, $stage-ink 100%); }
.vcard__thumb--c { background: linear-gradient(115deg, $color 0%, $subject-lilac 60%); }
.vcard__ring {
position: absolute;
left: 50%; top: 50%;
width: 30px; height: 30px;
margin: -15px 0 0 -15px;
border-radius: 50%;
border: 2px solid $subject-cream;
opacity: 0;
}
.vcard__play {
position: absolute;
left: 50%; top: 50%;
transform: translate(-50%, -50%);
width: 30px; height: 30px;
display: grid;
place-items: center;
border: 0;
border-radius: 50%;
background: rgba(255, 255, 255, .92);
color: $stage-ink;
font-size: 10px;
line-height: 1;
text-indent: 2px; // 삼각형 아이콘 시각 중심 보정 (padding 아님 — BD17 격자 대상 밖)
cursor: pointer;
transition: transform $duration $easing;
}
.vcard__time {
position: absolute;
right: $sp-1; bottom: $sp-1;
padding: 0 $sp-1;
background: rgba(23, 20, 26, .78);
color: $subject-cream;
font: 700 9px/1.4 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-family: inherit;
border-radius: $r-xs;
}
.vcard:hover .vcard__play, .vcard__play:focus-visible { transform: translate(-50%, -50%) scale(1.2); }
.vgallery.is-demo .vcard:nth-child(1) .vcard__play { animation: play-pulse $duration $easing infinite; }
.vgallery.is-demo .vcard:nth-child(1) .vcard__ring { animation: ring-pulse $duration $easing infinite; }
.vgallery.is-demo .vcard:nth-child(2) .vcard__play { animation: play-pulse $duration $easing infinite; animation-delay: -0.67s; }
.vgallery.is-demo .vcard:nth-child(2) .vcard__ring { animation: ring-pulse $duration $easing infinite; animation-delay: -0.67s; }
.vgallery.is-demo .vcard:nth-child(3) .vcard__play { animation: play-pulse $duration $easing infinite; animation-delay: -1.33s; }
.vgallery.is-demo .vcard:nth-child(3) .vcard__ring { animation: ring-pulse $duration $easing infinite; animation-delay: -1.33s; }
@keyframes play-pulse {
0%, 15% { transform: translate(-50%, -50%) scale(1); }
30% { transform: translate(-50%, -50%) scale(1.2); }
45%, 100% { transform: translate(-50%, -50%) scale(1); }
}
@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; }
}
@media (prefers-reduced-motion: reduce) {
.vcard__play { transition: none; }
.vgallery.is-demo .vcard__play, .vgallery.is-demo .vcard__ring { animation: 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);
}
.vgallery {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
list-style: none;
margin: 0;
padding: 0;
width: min(280px, 84vw);
}
.vcard {
position: relative;
aspect-ratio: 4/3;
overflow: hidden;
border-radius: 8px;
}
.vcard__thumb {
position: absolute;
inset: 0;
}
.vcard__thumb--a {
background: linear-gradient(155deg, #6ec8ff 0%, #17141a 100%);
}
.vcard__thumb--b {
background: linear-gradient(205deg, #b9a0ff 0%, #17141a 100%);
}
.vcard__thumb--c {
background: linear-gradient(115deg, #6ec8ff 0%, #b9a0ff 60%);
}
.vcard__ring {
position: absolute;
left: 50%;
top: 50%;
width: 30px;
height: 30px;
margin: -15px 0 0 -15px;
border-radius: 50%;
border: 2px solid #fff7e6;
opacity: 0;
}
.vcard__play {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
width: 30px;
height: 30px;
display: grid;
place-items: center;
border: 0;
border-radius: 50%;
background: rgba(255, 255, 255, 0.92);
color: #17141a;
font-size: 10px;
line-height: 1;
text-indent: 2px;
cursor: pointer;
transition: transform 2s ease-out;
}
.vcard__time {
position: absolute;
right: 4px;
bottom: 4px;
padding: 0 4px;
background: rgba(23, 20, 26, 0.78);
color: #fff7e6;
font: 700 9px/1.4 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-family: inherit;
border-radius: 4px;
}
.vcard:hover .vcard__play, .vcard__play:focus-visible {
transform: translate(-50%, -50%) scale(1.2);
}
.vgallery.is-demo .vcard:nth-child(1) .vcard__play {
animation: play-pulse 2s ease-out infinite;
}
.vgallery.is-demo .vcard:nth-child(1) .vcard__ring {
animation: ring-pulse 2s ease-out infinite;
}
.vgallery.is-demo .vcard:nth-child(2) .vcard__play {
animation: play-pulse 2s ease-out infinite;
animation-delay: -0.67s;
}
.vgallery.is-demo .vcard:nth-child(2) .vcard__ring {
animation: ring-pulse 2s ease-out infinite;
animation-delay: -0.67s;
}
.vgallery.is-demo .vcard:nth-child(3) .vcard__play {
animation: play-pulse 2s ease-out infinite;
animation-delay: -1.33s;
}
.vgallery.is-demo .vcard:nth-child(3) .vcard__ring {
animation: ring-pulse 2s ease-out infinite;
animation-delay: -1.33s;
}
@keyframes play-pulse {
0%, 15% {
transform: translate(-50%, -50%) scale(1);
}
30% {
transform: translate(-50%, -50%) scale(1.2);
}
45%, 100% {
transform: translate(-50%, -50%) scale(1);
}
}
@keyframes ring-pulse {
0%, 15% {
transform: scale(1);
opacity: 0;
}
30% {
opacity: 0.9;
}
60% {
transform: scale(1.9);
opacity: 0;
}
100% {
transform: scale(1);
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.vcard__play {
transition: none;
}
.vgallery.is-demo .vcard__play, .vgallery.is-demo .vcard__ring {
animation: none;
}
}import type { CSSProperties } from "react";
import styles from "./VideoThumbnailPlayOverlay.module.scss";
export type VideoThumbnailPlayOverlayProps = {
/** 재생버튼·파동 한 주기 (기본 2000ms) */
duration?: number;
easing?: string;
/** 썸네일 그러데이션 포인트 색 */
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const CLIPS = [
{ tone: "a", time: "0:32" },
{ tone: "b", time: "1:05" },
{ tone: "c", time: "0:48" },
];
/** 재생 버튼이 커지면서 둘레로 파동 링이 한 번 퍼져 나가 재생 가능한 영상임을 알려준다 */
export default function VideoThumbnailPlayOverlay({
duration = 2000,
easing = "ease-out",
color = "#6ec8ff",
}: VideoThumbnailPlayOverlayProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<ul className={styles.gallery} style={vars} aria-label="Video gallery">
{CLIPS.map((c, i) => (
<li key={i} className={styles.card}>
<span className={`${styles.thumb} ${styles["thumb-" + c.tone]}`} />
<span className={styles.ring} aria-hidden="true" style={{ animationDelay: `-${i * (duration / 3)}ms` }} />
<button type="button" className={styles.play} aria-label="Play video" style={{ animationDelay: `-${i * (duration / 3)}ms` }}>▶</button>
<span className={styles.time}>{c.time}</span>
</li>
))}
</ul>
);
}04 · 動画サムネイルグリッド 再生オーバーレイ — 計測 累積面積 4.782% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードのどこを見るか
動画を数本まとめて見せるメディアページに向いています。
.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()セレクタだけで薄く縮んで消えます。

<main class="stage">
<div class="pf-frame is-demo">
<div class="pf-chips" role="group" aria-label="カテゴリーで絞り込む">
<label class="pf-chip"><input type="checkbox" class="pf-check" data-cat="web"><span>Web</span></label>
<label class="pf-chip"><input type="checkbox" class="pf-check" data-cat="app"><span>App</span></label>
<label class="pf-chip"><input type="checkbox" class="pf-check" data-cat="brand"><span>Brand</span></label>
</div>
<div class="pf-grid">
<article class="pf-card" data-cat="web"><span class="pf-card__img pf-card__img--a"></span><span class="pf-card__title">Studio site</span></article>
<article class="pf-card" data-cat="app"><span class="pf-card__img pf-card__img--b"></span><span class="pf-card__title">Habit app</span></article>
<article class="pf-card" data-cat="brand"><span class="pf-card__img pf-card__img--c"></span><span class="pf-card__title">Cafe brand</span></article>
<article class="pf-card" data-cat="web"><span class="pf-card__img pf-card__img--d"></span><span class="pf-card__title">Shop site</span></article>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-base;
$easing: $ease-pop;
$color: $subject-mint;
@include stage($stage-paper);
.pf-frame { width: min(240px, 82vw); }
.pf-chips {
display: flex;
flex-wrap: wrap;
gap: $sp-1;
margin-bottom: $sp-2;
}
.pf-check {
position: absolute;
width: 1px; height: 1px;
opacity: 0;
pointer-events: none;
}
.pf-chip {
position: relative;
display: inline-flex;
align-items: center;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(23, 20, 26, .07);
color: $stage-ink;
font: 700 11px/1 -apple-system, "Segoe UI", sans-serif;
cursor: pointer;
transition: background $duration $easing, color $duration $easing;
-webkit-tap-highlight-color: transparent;
}
.pf-chip:has(:checked) { background: $color; color: $stage-paper; }
.pf-chip:has(:focus-visible) { outline: 2px solid $stage-ink; outline-offset: 2px; }
// 세로로 안 쌓이게 한 줄(4칸)로 — 폰 세로 200px 높이 예산 안에 들어가야 한다
.pf-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: $sp-1;
}
.pf-card {
position: relative;
border-radius: $r-sm;
overflow: hidden;
transition: opacity $duration $easing, transform $duration $easing;
}
.pf-card__img { display: block; aspect-ratio: 3 / 4; }
.pf-card__img--a { background: linear-gradient(150deg, $color, $subject-sky); }
.pf-card__img--b { background: linear-gradient(150deg, $subject-lilac, $color); }
.pf-card__img--c { background: linear-gradient(150deg, $subject-pink, $color); }
.pf-card__img--d { background: linear-gradient(150deg, $subject-sky, $subject-lilac); }
.pf-card__title {
display: block;
padding: $sp-1 $sp-2;
font: 700 10px/1.3 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-family: inherit;
color: $stage-ink;
background: $subject-cream;
}
// 실사용: 체크칩을 하나라도 켜면 나머지 카드가 옅어지고, 켠 칩과 같은 카테고리 카드만 다시 선명해진다 (JS 0줄, :has() 만)
.pf-frame:has(.pf-check:checked) .pf-card { opacity: .18; transform: scale(.92); pointer-events: none; }
.pf-frame:has(input[data-cat="web"]:checked) .pf-card[data-cat="web"],
.pf-frame:has(input[data-cat="app"]:checked) .pf-card[data-cat="app"],
.pf-frame:has(input[data-cat="brand"]:checked) .pf-card[data-cat="brand"] { opacity: 1; transform: scale(1); pointer-events: auto; }
@media (prefers-reduced-motion: reduce) {
.pf-chip, .pf-card { transition: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.pf-frame {
width: min(240px, 82vw);
}
.pf-chips {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-bottom: 8px;
}
.pf-check {
position: absolute;
width: 1px;
height: 1px;
opacity: 0;
pointer-events: none;
}
.pf-chip {
position: relative;
display: inline-flex;
align-items: center;
padding: 4px 12px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.07);
color: #17141a;
font: 700 11px/1 -apple-system, "Segoe UI", sans-serif;
cursor: pointer;
transition: background 300ms cubic-bezier(0.2, 1.4, 0.4, 1), color 300ms cubic-bezier(0.2, 1.4, 0.4, 1);
-webkit-tap-highlight-color: transparent;
}
.pf-chip:has(:checked) {
background: #4cd4a6;
color: #ffffff;
}
.pf-chip:has(:focus-visible) {
outline: 2px solid #17141a;
outline-offset: 2px;
}
.pf-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 4px;
}
.pf-card {
position: relative;
border-radius: 8px;
overflow: hidden;
transition: opacity 300ms cubic-bezier(0.2, 1.4, 0.4, 1), transform 300ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.pf-card__img {
display: block;
aspect-ratio: 3/4;
}
.pf-card__img--a {
background: linear-gradient(150deg, #4cd4a6, #6ec8ff);
}
.pf-card__img--b {
background: linear-gradient(150deg, #b9a0ff, #4cd4a6);
}
.pf-card__img--c {
background: linear-gradient(150deg, #ff9ebb, #4cd4a6);
}
.pf-card__img--d {
background: linear-gradient(150deg, #6ec8ff, #b9a0ff);
}
.pf-card__title {
display: block;
padding: 4px 8px;
font: 700 10px/1.3 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-family: inherit;
color: #17141a;
background: #fff7e6;
}
.pf-frame:has(.pf-check:checked) .pf-card {
opacity: 0.18;
transform: scale(0.92);
pointer-events: none;
}
.pf-frame:has(input[data-cat=web]:checked) .pf-card[data-cat=web],
.pf-frame:has(input[data-cat=app]:checked) .pf-card[data-cat=app],
.pf-frame:has(input[data-cat=brand]:checked) .pf-card[data-cat=brand] {
opacity: 1;
transform: scale(1);
pointer-events: auto;
}
@media (prefers-reduced-motion: reduce) {
.pf-chip, .pf-card {
transition: none !important;
}
}(function () {
var frame = document.querySelector('.pf-frame');
if (!frame.classList.contains('is-demo')) return;
var boxes = document.querySelectorAll('.pf-check'), i = 0;
var iv = setInterval(function () {
boxes.forEach(function (b) { b.checked = false; });
boxes[i].checked = true;
i = (i + 1) % boxes.length;
}, 700);
boxes.forEach(function (b) {
b.addEventListener('change', function () { frame.classList.remove('is-demo'); clearInterval(iv); });
});
})();import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./TagFilterPortfolioGrid.module.scss";
export type TagFilterPortfolioGridProps = {
duration?: number;
easing?: string;
/** 활성 태그·인디케이터 색 */
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const TAGS = ["web", "app", "brand"] as const;
const CARDS = [
{ cat: "web", tone: "a", title: "Studio site" },
{ cat: "app", tone: "b", title: "Habit app" },
{ cat: "brand", tone: "c", title: "Cafe brand" },
{ cat: "web", tone: "d", title: "Shop site" },
];
/** 태그 칩을 클릭하면 해당 카테고리가 아닌 카드가 옅어지며 줄어든다 (다중 선택 가능) */
export default function TagFilterPortfolioGrid({
duration = 300,
easing = "cubic-bezier(.2,1.4,.4,1)",
color = "#4cd4a6",
}: TagFilterPortfolioGridProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const [active, setActive] = useState<Set<string>>(new Set());
function toggle(tag: string) {
setActive((prev) => {
const next = new Set(prev);
if (next.has(tag)) next.delete(tag); else next.add(tag);
return next;
});
}
const filtering = active.size > 0;
return (
<div className={styles.frame} style={vars}>
<div className={styles.chips} role="group" aria-label="Filter by category">
{TAGS.map((tag) => (
<label key={tag} className={styles.chip}>
<input
type="checkbox"
className={styles.check}
checked={active.has(tag)}
onChange={() => toggle(tag)}
/>
<span>{tag[0].toUpperCase() + tag.slice(1)}</span>
</label>
))}
</div>
<div className={styles.grid}>
{CARDS.map((c, i) => {
const dim = filtering && !active.has(c.cat);
return (
<article key={i} className={`${styles.card} ${dim ? styles.dim : ""}`}>
<span className={`${styles.img} ${styles["img-" + c.tone]}`} />
<span className={styles.title}>{c.title}</span>
</article>
);
})}
</div>
</div>
);
}05 · タグ絞り込みポートフォリオグリッド — 計測 累積面積 19.24% · 動いたフレーム 11/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードの読み方
Web・アプリ・ブランディングを分けて見せるエージェンシーの紹介ページに合います。
.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枚の位置が入れ替わり、離すと残りのマスも自然に整列し直します。

<main class="stage">
<ul class="rg-list is-demo" aria-label="ドラッグまたは矢印キーで写真の順番を変更">
<li class="rg-tile" tabindex="0"><span class="rg-tile__img rg-tile__img--a"></span><span class="rg-tile__handle" aria-hidden="true">⠿</span></li>
<li class="rg-tile" tabindex="0"><span class="rg-tile__img rg-tile__img--b"></span><span class="rg-tile__handle" aria-hidden="true">⠿</span></li>
<li class="rg-tile" tabindex="0"><span class="rg-tile__img rg-tile__img--c"></span><span class="rg-tile__handle" aria-hidden="true">⠿</span></li>
</ul>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick; // 스왑 스냅 시간
$easing: $ease-out;
$color: $subject-blue; // 핸들·포커스 색
@include stage($stage-paper);
.rg-list {
position: relative;
display: flex;
list-style: none;
margin: 0;
padding: 0;
gap: $sp-2;
}
.rg-tile {
position: relative;
width: 64px;
height: 64px;
border-radius: $r-sm;
outline: none;
cursor: grab;
touch-action: none;
box-shadow: $shadow-press;
transition: transform $duration $easing;
&.is-dragging { transition: none; z-index: 2; box-shadow: $shadow-raised; cursor: grabbing; }
}
.rg-tile:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
.rg-tile__img { position: absolute; inset: 0; border-radius: inherit; }
.rg-tile__img--a { background: linear-gradient(155deg, $color 0%, $stage-ink 100%); }
.rg-tile__img--b { background: linear-gradient(205deg, $subject-cream 0%, $color 100%); }
.rg-tile__img--c { background: linear-gradient(115deg, $color 0%, $subject-cream 60%); }
.rg-tile__handle {
position: absolute;
right: 2px; bottom: 2px;
width: 16px; height: 16px;
display: grid;
place-items: center;
background: rgba(255, 255, 255, .88);
border-radius: $r-xs;
font-size: 9px;
line-height: 1;
color: $stage-ink;
}
.rg-list.is-demo .rg-tile:nth-child(1) { animation: swap-a $dur-loop $easing infinite; }
.rg-list.is-demo .rg-tile:nth-child(2) { animation: swap-b $dur-loop $easing infinite; }
@keyframes swap-a {
0%, 10% { transform: translateX(0); }
45%, 65% { transform: translateX(72px); }
95%, 100% { transform: translateX(0); }
}
@keyframes swap-b {
0%, 10% { transform: translateX(0); }
45%, 65% { transform: translateX(-72px); }
95%, 100% { transform: translateX(0); }
}
@media (prefers-reduced-motion: reduce) {
.rg-tile { transition: none; }
.rg-list.is-demo .rg-tile:nth-child(1),
.rg-list.is-demo .rg-tile:nth-child(2) { animation: none; }
}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);
}
.rg-list {
position: relative;
display: flex;
list-style: none;
margin: 0;
padding: 0;
gap: 8px;
}
.rg-tile {
position: relative;
width: 64px;
height: 64px;
border-radius: 8px;
outline: none;
cursor: grab;
touch-action: none;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transition: transform 220ms ease-out;
}
.rg-tile.is-dragging {
transition: none;
z-index: 2;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
cursor: grabbing;
}
.rg-tile:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.rg-tile__img {
position: absolute;
inset: 0;
border-radius: inherit;
}
.rg-tile__img--a {
background: linear-gradient(155deg, #2f6df6 0%, #17141a 100%);
}
.rg-tile__img--b {
background: linear-gradient(205deg, #fff7e6 0%, #2f6df6 100%);
}
.rg-tile__img--c {
background: linear-gradient(115deg, #2f6df6 0%, #fff7e6 60%);
}
.rg-tile__handle {
position: absolute;
right: 2px;
bottom: 2px;
width: 16px;
height: 16px;
display: grid;
place-items: center;
background: rgba(255, 255, 255, 0.88);
border-radius: 4px;
font-size: 9px;
line-height: 1;
color: #17141a;
}
.rg-list.is-demo .rg-tile:nth-child(1) {
animation: swap-a 2s ease-out infinite;
}
.rg-list.is-demo .rg-tile:nth-child(2) {
animation: swap-b 2s ease-out infinite;
}
@keyframes swap-a {
0%, 10% {
transform: translateX(0);
}
45%, 65% {
transform: translateX(72px);
}
95%, 100% {
transform: translateX(0);
}
}
@keyframes swap-b {
0%, 10% {
transform: translateX(0);
}
45%, 65% {
transform: translateX(-72px);
}
95%, 100% {
transform: translateX(0);
}
}
@media (prefers-reduced-motion: reduce) {
.rg-tile {
transition: none;
}
.rg-list.is-demo .rg-tile:nth-child(1),
.rg-list.is-demo .rg-tile:nth-child(2) {
animation: none;
}
}(function () {
var list = document.querySelector('.rg-list');
Array.prototype.forEach.call(list.children, function (tile) {
var startX = 0, dx = 0, dragging = false;
tile.addEventListener('pointerdown', function (e) {
list.classList.remove('is-demo');
dragging = true; startX = e.clientX; tile.classList.add('is-dragging');
tile.setPointerCapture(e.pointerId);
});
tile.addEventListener('pointermove', function (e) {
if (!dragging) return;
dx = e.clientX - startX;
tile.style.transform = 'translateX(' + dx + 'px)';
});
function end() {
if (!dragging) return;
dragging = false; tile.classList.remove('is-dragging');
var target = dx > 30 ? tile.nextElementSibling : dx < -30 ? tile.previousElementSibling : null;
tile.style.transform = '';
if (target) list.insertBefore(dx > 0 ? target : tile, dx > 0 ? tile : target);
dx = 0;
}
tile.addEventListener('pointerup', end);
tile.addEventListener('pointercancel', end);
tile.addEventListener('keydown', function (e) {
list.classList.remove('is-demo');
var sib = e.key === 'ArrowRight' ? tile.nextElementSibling : e.key === 'ArrowLeft' ? tile.previousElementSibling : null;
if (!sib) return;
e.preventDefault();
if (e.key === 'ArrowRight') list.insertBefore(sib, tile); else list.insertBefore(tile, sib);
tile.focus({ preventScroll: true });
});
});
})();import { useRef, useState } from "react";
import type { CSSProperties, PointerEvent as ReactPointerEvent } from "react";
import styles from "./DragReorderPhotoGrid.module.scss";
type ReactKeyboardEvent<T> = { key: string; preventDefault: () => void; currentTarget: T };
export type DragReorderPhotoGridProps = {
/** 스왑 스냅 시간 (기본 220ms) */
duration?: number;
easing?: string;
/** 핸들 색 */
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const TONES = ["a", "b", "c"] as const;
/** 사진 칸을 밀어 옆 칸과 자리를 바꾼다 — 손가락·마우스 드래그, 좌우 화살표 키 둘 다 된다 */
export default function DragReorderPhotoGrid({
duration = 220,
easing = "ease-out",
color = "#2f6df6",
}: DragReorderPhotoGridProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const [order, setOrder] = useState<number[]>([0, 1, 2]);
const dragState = useRef<{ pos: number; startX: number; dx: number } | null>(null);
const [dragging, setDragging] = useState<number | null>(null);
const [offset, setOffset] = useState(0);
function swap(pos: number, dir: 1 | -1) {
setOrder((prev) => {
const next = [...prev];
const target = pos + dir;
if (target < 0 || target >= next.length) return prev;
[next[pos], next[target]] = [next[target], next[pos]];
return next;
});
}
function onPointerDown(e: ReactPointerEvent<HTMLLIElement>, pos: number) {
(e.target as Element).setPointerCapture(e.pointerId);
dragState.current = { pos, startX: e.clientX, dx: 0 };
setDragging(pos);
}
function onPointerMove(e: ReactPointerEvent<HTMLLIElement>) {
if (!dragState.current) return;
dragState.current.dx = e.clientX - dragState.current.startX;
setOffset(dragState.current.dx);
}
function onPointerUp() {
if (!dragState.current) return;
const { pos, dx } = dragState.current;
if (dx > 30) swap(pos, 1);
else if (dx < -30) swap(pos, -1);
dragState.current = null;
setDragging(null);
setOffset(0);
}
function onKeyDown(e: ReactKeyboardEvent<HTMLLIElement>, pos: number) {
if (e.key === "ArrowRight") { e.preventDefault(); swap(pos, 1); }
else if (e.key === "ArrowLeft") { e.preventDefault(); swap(pos, -1); }
}
return (
<ul className={styles.list} style={vars} aria-label="Drag or use arrow keys to reorder photos">
{order.map((tileId, pos) => (
<li
key={tileId}
tabIndex={0}
className={`${styles.tile} ${dragging === pos ? styles.isDragging : ""}`}
style={dragging === pos ? { transform: `translateX(${offset}px)` } : undefined}
onPointerDown={(e: ReactPointerEvent<HTMLLIElement>) => onPointerDown(e, pos)}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
onKeyDown={(e: ReactKeyboardEvent<HTMLLIElement>) => onKeyDown(e, pos)}
>
<span className={`${styles.img} ${styles["img-" + TONES[tileId]]}`} />
<span className={styles.handle} aria-hidden="true">⠿</span>
</li>
))}
</ul>
);
}06 · ドラッグで並び替える写真グリッド — 計測 累積面積 7.467% · 動いたフレーム 17/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
どこを変えるか
代表写真を自分で選ぶ商品画像管理画面に使います。
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枚が画面幅いっぱいに表示されて自動で切り替わり、下のサムネイル列では今表示中の写真の枠だけが光ります。
<main class="stage">
<div class="ss-wrap">
<div class="ss-viewport" tabindex="0" aria-label="スライドショー、左右矢印キーで移動">
<ul class="ss-track is-demo">
<li class="ss-slide ss-slide--a"></li>
<li class="ss-slide ss-slide--b"></li>
<li class="ss-slide ss-slide--c"></li>
<li class="ss-slide ss-slide--d"></li>
<li class="ss-slide ss-slide--a" aria-hidden="true"></li>
</ul>
</div>
<div class="ss-thumbs is-demo" role="tablist" aria-label="スライドを選ぶ">
<button class="ss-thumb ss-thumb--a" type="button" role="tab" aria-label="1枚目の写真"></button>
<button class="ss-thumb ss-thumb--b" type="button" role="tab" aria-label="2枚目の写真"></button>
<button class="ss-thumb ss-thumb--c" type="button" role="tab" aria-label="3枚目の写真"></button>
<button class="ss-thumb ss-thumb--d" type="button" role="tab" aria-label="4枚目の写真"></button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-loop; // 슬라이드 한 바퀴 도는 시간 (설정 초 값과 일치)
$easing: linear;
$color: $subject-sky; // 활성 썸네일 테두리 색
@include stage($stage-ink);
.ss-wrap { width: min(200px, 78vw); }
.ss-viewport {
overflow: hidden;
border-radius: $r-card;
outline: none;
}
.ss-viewport:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
.ss-track {
display: flex;
list-style: none;
margin: 0 0 $sp-2;
padding: 0;
width: 500%; // 실제 4장 + 1번 클론 = 5칸
}
.ss-slide {
flex: 0 0 20%;
aspect-ratio: 8 / 5;
border-radius: $r-card;
}
.ss-slide--a { background: linear-gradient(155deg, $color 0%, $stage-ink 100%); }
.ss-slide--b { background: linear-gradient(205deg, $subject-lilac 0%, $color 100%); }
.ss-slide--c { background: linear-gradient(115deg, $color 0%, $subject-cream 55%); }
.ss-slide--d { background: linear-gradient(250deg, $subject-pink 0%, $color 100%); }
// 클론(5번째 칸)이 1번째와 픽셀 단위로 같은 그러데이션이라, -80% 에서 한 바퀴가 끝나고
// 다음 순간 0% 로 되돌아가도(1번째 실칸) 이어붙인 자리가 안 보인다 — "1번 슬라이드 클론" 기법
.ss-track.is-demo { animation: slide-loop $duration $easing 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%); }
}
.ss-thumbs {
display: flex;
gap: $sp-2;
list-style: none;
margin: 0;
padding: 0;
}
.ss-thumb {
flex: 1 1 0;
aspect-ratio: 8 / 5;
border: 2px solid transparent;
border-radius: $r-sm;
padding: 0;
cursor: pointer;
opacity: .5;
transition: opacity $duration $easing, border-color $duration $easing;
}
.ss-thumb--a { background: linear-gradient(155deg, $color 0%, $stage-ink 100%); }
.ss-thumb--b { background: linear-gradient(205deg, $subject-lilac 0%, $color 100%); }
.ss-thumb--c { background: linear-gradient(115deg, $color 0%, $subject-cream 55%); }
.ss-thumb--d { background: linear-gradient(250deg, $subject-pink 0%, $color 100%); }
.ss-thumb.is-active, .ss-thumb:focus-visible { opacity: 1; border-color: $color; }
.ss-thumbs.is-demo .ss-thumb--a { animation: thumb-a $duration $easing infinite; }
.ss-thumbs.is-demo .ss-thumb--b { animation: thumb-b $duration $easing infinite; }
.ss-thumbs.is-demo .ss-thumb--c { animation: thumb-c $duration $easing infinite; }
.ss-thumbs.is-demo .ss-thumb--d { animation: thumb-d $duration $easing infinite; }
@keyframes thumb-a {
0%, 18%, 80%, 100% { opacity: 1; border-color: $color; }
20%, 78% { opacity: .5; border-color: transparent; }
}
@keyframes thumb-b {
20%, 38% { opacity: 1; border-color: $color; }
0%, 18%, 40%, 100% { opacity: .5; border-color: transparent; }
}
@keyframes thumb-c {
40%, 58% { opacity: 1; border-color: $color; }
0%, 38%, 60%, 100% { opacity: .5; border-color: transparent; }
}
@keyframes thumb-d {
60%, 78% { opacity: 1; border-color: $color; }
0%, 58%, 80%, 100% { opacity: .5; border-color: transparent; }
}
@media (prefers-reduced-motion: reduce) {
.ss-thumb { transition: none; }
.ss-track.is-demo, .ss-thumbs.is-demo .ss-thumb { animation: 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);
}
.ss-wrap {
width: min(200px, 78vw);
}
.ss-viewport {
overflow: hidden;
border-radius: 16px;
outline: none;
}
.ss-viewport:focus-visible {
outline: 2px solid #6ec8ff;
outline-offset: 2px;
}
.ss-track {
display: flex;
list-style: none;
margin: 0 0 8px;
padding: 0;
width: 500%;
}
.ss-slide {
flex: 0 0 20%;
aspect-ratio: 8/5;
border-radius: 16px;
}
.ss-slide--a {
background: linear-gradient(155deg, #6ec8ff 0%, #17141a 100%);
}
.ss-slide--b {
background: linear-gradient(205deg, #b9a0ff 0%, #6ec8ff 100%);
}
.ss-slide--c {
background: linear-gradient(115deg, #6ec8ff 0%, #fff7e6 55%);
}
.ss-slide--d {
background: linear-gradient(250deg, #ff9ebb 0%, #6ec8ff 100%);
}
.ss-track.is-demo {
animation: slide-loop 2s 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%);
}
}
.ss-thumbs {
display: flex;
gap: 8px;
list-style: none;
margin: 0;
padding: 0;
}
.ss-thumb {
flex: 1 1 0;
aspect-ratio: 8/5;
border: 2px solid transparent;
border-radius: 8px;
padding: 0;
cursor: pointer;
opacity: 0.5;
transition: opacity 2s linear, border-color 2s linear;
}
.ss-thumb--a {
background: linear-gradient(155deg, #6ec8ff 0%, #17141a 100%);
}
.ss-thumb--b {
background: linear-gradient(205deg, #b9a0ff 0%, #6ec8ff 100%);
}
.ss-thumb--c {
background: linear-gradient(115deg, #6ec8ff 0%, #fff7e6 55%);
}
.ss-thumb--d {
background: linear-gradient(250deg, #ff9ebb 0%, #6ec8ff 100%);
}
.ss-thumb.is-active, .ss-thumb:focus-visible {
opacity: 1;
border-color: #6ec8ff;
}
.ss-thumbs.is-demo .ss-thumb--a {
animation: thumb-a 2s linear infinite;
}
.ss-thumbs.is-demo .ss-thumb--b {
animation: thumb-b 2s linear infinite;
}
.ss-thumbs.is-demo .ss-thumb--c {
animation: thumb-c 2s linear infinite;
}
.ss-thumbs.is-demo .ss-thumb--d {
animation: thumb-d 2s linear infinite;
}
@keyframes thumb-a {
0%, 18%, 80%, 100% {
opacity: 1;
border-color: #6ec8ff;
}
20%, 78% {
opacity: 0.5;
border-color: transparent;
}
}
@keyframes thumb-b {
20%, 38% {
opacity: 1;
border-color: #6ec8ff;
}
0%, 18%, 40%, 100% {
opacity: 0.5;
border-color: transparent;
}
}
@keyframes thumb-c {
40%, 58% {
opacity: 1;
border-color: #6ec8ff;
}
0%, 38%, 60%, 100% {
opacity: 0.5;
border-color: transparent;
}
}
@keyframes thumb-d {
60%, 78% {
opacity: 1;
border-color: #6ec8ff;
}
0%, 58%, 80%, 100% {
opacity: 0.5;
border-color: transparent;
}
}
@media (prefers-reduced-motion: reduce) {
.ss-thumb {
transition: none;
}
.ss-track.is-demo, .ss-thumbs.is-demo .ss-thumb {
animation: none;
}
}(function () {
var viewport = document.querySelector('.ss-viewport');
var track = document.querySelector('.ss-track');
var thumbsWrap = document.querySelector('.ss-thumbs');
var thumbs = document.querySelectorAll('.ss-thumb');
var real = 4, current = 0;
function stop() { track.classList.remove('is-demo'); thumbsWrap.classList.remove('is-demo'); }
function go(i) {
stop();
current = i;
track.style.transform = 'translateX(-' + (i * 20) + '%)';
thumbs.forEach(function (t, idx) { t.setAttribute('aria-selected', idx === i ? 'true' : 'false'); t.classList.toggle('is-active', idx === i); });
}
thumbs.forEach(function (t, i) { t.addEventListener('click', function () { go(i); }); });
viewport.addEventListener('keydown', function (e) {
if (e.key === 'ArrowRight') { go((current + 1) % real); e.preventDefault(); }
else if (e.key === 'ArrowLeft') { go((current - 1 + real) % real); e.preventDefault(); }
});
})();import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./FullscreenSlideshowThumbnailStrip.module.scss";
type ReactKeyboardEvent<T> = { key: string; preventDefault: () => void; currentTarget: T };
export type FullscreenSlideshowThumbnailStripProps = {
duration?: number;
easing?: string;
/** 활성 썸네일 테두리 색 */
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const TONES = ["a", "b", "c", "d"] as const;
/**
* 큰 사진 한 장이 자동으로 넘어가고, 화살표 키나 썸네일 클릭으로도 이동할 수 있다.
* 자동 재생 트랙엔 1번 슬라이드의 클론을 5번째 칸으로 붙여 두어 (vanilla 와 동일한 기법)
* 한 바퀴 끝(80%)이 1번째(0%)와 같은 그림이라 되돌아가는 지점이 안 보인다.
*/
export default function FullscreenSlideshowThumbnailStrip({
duration = 2000,
easing = "linear",
color = "#6ec8ff",
}: FullscreenSlideshowThumbnailStripProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const [current, setCurrent] = useState(0);
const [autoplay, setAutoplay] = useState(true);
function goTo(i: number) {
setAutoplay(false);
setCurrent(i);
}
function onKeyDown(e: ReactKeyboardEvent<HTMLDivElement>) {
if (e.key === "ArrowRight") { goTo((current + 1) % TONES.length); e.preventDefault(); }
else if (e.key === "ArrowLeft") { goTo((current - 1 + TONES.length) % TONES.length); e.preventDefault(); }
}
const trackStyle: Vars = autoplay ? vars : { ...vars, transform: `translateX(-${current * 20}%)` };
return (
<div className={styles.wrap} style={vars}>
<div
className={styles.viewport}
tabIndex={0}
aria-label="Slideshow, use the left and right arrow keys to move"
onKeyDown={onKeyDown}
>
<ul className={`${styles.track} ${autoplay ? styles.autoplay : ""}`} style={trackStyle}>
{/* 실제 4장 + 1번째 클론(5번째 칸, aria-hidden) — 자동 루프가 이 5칸을 -80%까지 훑는다 */}
{[...TONES, TONES[0]].map((tone, i) => (
<li key={i} className={`${styles.slide} ${styles["slide-" + tone]}`} aria-hidden={i === TONES.length} />
))}
</ul>
</div>
<div className={styles.thumbs} role="tablist" aria-label="Choose a slide">
{TONES.map((tone, i) => (
<button
key={i}
type="button"
role="tab"
aria-selected={!autoplay && current === i}
aria-label={`Photo ${i + 1}`}
className={[
styles.thumb,
styles["thumb-" + tone],
autoplay ? styles["autoThumb-" + tone] : (current === i ? styles.active : ""),
].join(" ")}
onClick={() => goTo(i)}
/>
))}
</div>
</div>
);
}07 · 全幅スライドショー サムネイルストリップ — 計測 累積面積 20.474% · 動いたフレーム 6/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードのどこを見るか
代表作数点を順番にめくるポートフォリオのヒーローに向いています。
.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スクリムキャプションリビールギャラリー
画面で起きること
普段は写真だけが見え、カーソルを乗せると画像全体に暗いスクリムが広がるように現れ、中央にタイトルが浮かび上がります。

<main class="stage">
<ul class="cr-gallery is-demo" aria-label="キャプション付きの写真ギャラリー">
<li class="cr-card" tabindex="0">
<span class="cr-card__img cr-card__img--a"></span>
<span class="cr-card__scrim" aria-hidden="true"></span>
<span class="cr-card__caption">Kyoto, 2024</span>
</li>
<li class="cr-card" tabindex="0">
<span class="cr-card__img cr-card__img--b"></span>
<span class="cr-card__scrim" aria-hidden="true"></span>
<span class="cr-card__caption">Studio Nine</span>
</li>
<li class="cr-card" tabindex="0">
<span class="cr-card__img cr-card__img--c"></span>
<span class="cr-card__scrim" aria-hidden="true"></span>
<span class="cr-card__caption">Seoul rooftop</span>
</li>
</ul>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base; // 스크림·캡션 리빌 시간
$easing: $ease-out;
$color: $subject-lilac; // 사진 그러데이션 포인트
@include stage($stage-ink);
.cr-gallery {
display: flex;
gap: $sp-2;
list-style: none;
margin: 0;
padding: 0;
width: min(260px, 84vw);
}
.cr-card {
position: relative;
flex: 1 1 0;
aspect-ratio: 3 / 4;
overflow: hidden;
border-radius: $r-sm;
outline: none;
cursor: pointer;
}
.cr-card__img { position: absolute; inset: 0; }
.cr-card__img--a { background: linear-gradient(155deg, $color 0%, $stage-ink 100%); }
.cr-card__img--b { background: linear-gradient(205deg, $subject-sky 0%, $color 100%); }
.cr-card__img--c { background: linear-gradient(115deg, $color 0%, $subject-pink 60%); }
// 132 편의 "하단 띠" 방식과 달리, 사진 전체가 어두워지는 스크림 방식
.cr-card__scrim {
position: absolute;
inset: 0;
background: radial-gradient(120% 120% at 50% 65%, rgba(23, 20, 26, 0) 0%, rgba(23, 20, 26, .86) 100%);
opacity: 0;
transition: opacity $duration $easing;
}
.cr-card__caption {
position: absolute;
left: $sp-2; right: $sp-2; bottom: $sp-3;
color: $subject-cream;
font: 700 12px/1.4 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-family: inherit;
text-align: center;
opacity: 0;
transform: translateY(6px);
transition: opacity $duration $easing, transform $duration $easing;
}
.cr-card:hover .cr-card__scrim, .cr-card:focus-visible .cr-card__scrim { opacity: 1; }
.cr-card:hover .cr-card__caption, .cr-card:focus-visible .cr-card__caption { opacity: 1; transform: translateY(0); }
.cr-gallery.is-demo .cr-card:nth-child(1) .cr-card__scrim,
.cr-gallery.is-demo .cr-card:nth-child(1) .cr-card__caption { animation-delay: 0s; }
.cr-gallery.is-demo .cr-card:nth-child(2) .cr-card__scrim,
.cr-gallery.is-demo .cr-card:nth-child(2) .cr-card__caption { animation-delay: -0.67s; }
.cr-gallery.is-demo .cr-card:nth-child(3) .cr-card__scrim,
.cr-gallery.is-demo .cr-card:nth-child(3) .cr-card__caption { animation-delay: -1.33s; }
.cr-gallery.is-demo .cr-card__scrim { animation: scrim-loop $dur-loop $easing infinite; }
.cr-gallery.is-demo .cr-card__caption { animation: caption-loop $dur-loop $easing infinite; }
@keyframes scrim-loop {
0%, 10% { opacity: 0; }
25%, 45% { opacity: 1; }
60%, 100% { opacity: 0; }
}
@keyframes caption-loop {
0%, 10% { opacity: 0; transform: translateY(6px); }
25%, 45% { opacity: 1; transform: translateY(0); }
60%, 100% { opacity: 0; transform: translateY(6px); }
}
@media (prefers-reduced-motion: reduce) {
.cr-card__scrim, .cr-card__caption { transition: none; }
.cr-gallery.is-demo .cr-card__scrim, .cr-gallery.is-demo .cr-card__caption { animation: 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);
}
.cr-gallery {
display: flex;
gap: 8px;
list-style: none;
margin: 0;
padding: 0;
width: min(260px, 84vw);
}
.cr-card {
position: relative;
flex: 1 1 0;
aspect-ratio: 3/4;
overflow: hidden;
border-radius: 8px;
outline: none;
cursor: pointer;
}
.cr-card__img {
position: absolute;
inset: 0;
}
.cr-card__img--a {
background: linear-gradient(155deg, #b9a0ff 0%, #17141a 100%);
}
.cr-card__img--b {
background: linear-gradient(205deg, #6ec8ff 0%, #b9a0ff 100%);
}
.cr-card__img--c {
background: linear-gradient(115deg, #b9a0ff 0%, #ff9ebb 60%);
}
.cr-card__scrim {
position: absolute;
inset: 0;
background: radial-gradient(120% 120% at 50% 65%, rgba(23, 20, 26, 0) 0%, rgba(23, 20, 26, 0.86) 100%);
opacity: 0;
transition: opacity 300ms ease-out;
}
.cr-card__caption {
position: absolute;
left: 8px;
right: 8px;
bottom: 12px;
color: #fff7e6;
font: 700 12px/1.4 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-family: inherit;
text-align: center;
opacity: 0;
transform: translateY(6px);
transition: opacity 300ms ease-out, transform 300ms ease-out;
}
.cr-card:hover .cr-card__scrim, .cr-card:focus-visible .cr-card__scrim {
opacity: 1;
}
.cr-card:hover .cr-card__caption, .cr-card:focus-visible .cr-card__caption {
opacity: 1;
transform: translateY(0);
}
.cr-gallery.is-demo .cr-card:nth-child(1) .cr-card__scrim,
.cr-gallery.is-demo .cr-card:nth-child(1) .cr-card__caption {
animation-delay: 0s;
}
.cr-gallery.is-demo .cr-card:nth-child(2) .cr-card__scrim,
.cr-gallery.is-demo .cr-card:nth-child(2) .cr-card__caption {
animation-delay: -0.67s;
}
.cr-gallery.is-demo .cr-card:nth-child(3) .cr-card__scrim,
.cr-gallery.is-demo .cr-card:nth-child(3) .cr-card__caption {
animation-delay: -1.33s;
}
.cr-gallery.is-demo .cr-card__scrim {
animation: scrim-loop 2s ease-out infinite;
}
.cr-gallery.is-demo .cr-card__caption {
animation: caption-loop 2s ease-out infinite;
}
@keyframes scrim-loop {
0%, 10% {
opacity: 0;
}
25%, 45% {
opacity: 1;
}
60%, 100% {
opacity: 0;
}
}
@keyframes caption-loop {
0%, 10% {
opacity: 0;
transform: translateY(6px);
}
25%, 45% {
opacity: 1;
transform: translateY(0);
}
60%, 100% {
opacity: 0;
transform: translateY(6px);
}
}
@media (prefers-reduced-motion: reduce) {
.cr-card__scrim, .cr-card__caption {
transition: none;
}
.cr-gallery.is-demo .cr-card__scrim, .cr-gallery.is-demo .cr-card__caption {
animation: none;
}
}import type { CSSProperties } from "react";
import styles from "./CaptionRevealHoverScrim.module.scss";
export type CaptionRevealHoverScrimProps = {
duration?: number;
easing?: string;
/** 사진 그러데이션 포인트 색 */
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const CARDS = [
{ tone: "a", caption: "Kyoto, 2024" },
{ tone: "b", caption: "Studio Nine" },
{ tone: "c", caption: "Seoul rooftop" },
];
/** 커서를 올리면 사진 전체 위로 어두운 스크림이 번지듯 깔리며 캡션이 함께 떠오른다 */
export default function CaptionRevealHoverScrim({
duration = 300,
easing = "ease-out",
color = "#b9a0ff",
}: CaptionRevealHoverScrimProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<ul className={styles.gallery} style={vars} aria-label="A gallery of photos with captions">
{CARDS.map((c, i) => (
<li key={i} className={styles.card} tabIndex={0}>
<span className={`${styles.img} ${styles["img-" + c.tone]}`} />
<span className={styles.scrim} aria-hidden="true" />
<span className={styles.caption}>{c.caption}</span>
</li>
))}
</ul>
);
}08 · スクリムキャプションリビールギャラリー — 計測 累積面積 17.063% · 動いたフレーム 12/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードの読み方
端から帯が上がる方式と違って写真全体が暗くなるので、写真そのものが読み取りやすいままです。
.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つクリックしてもモーダルは開かず、グリッド自体が組み替わり、選んだマスがその場で拡大し残りのマスはその場で薄く縮みます。

<main class="stage">
<div class="g-frame is-demo">
<div class="g-grid" role="radiogroup" aria-label="写真を選んで大きく見る">
<label class="g-tile"><input type="radio" name="g-sel" class="g-radio" aria-label="1枚目の写真を大きく見る"><span class="g-tile__img g-tile__img--a"></span></label>
<label class="g-tile"><input type="radio" name="g-sel" class="g-radio" aria-label="2枚目の写真を大きく見る"><span class="g-tile__img g-tile__img--b"></span></label>
<label class="g-tile"><input type="radio" name="g-sel" class="g-radio" aria-label="3枚目の写真を大きく見る"><span class="g-tile__img g-tile__img--c"></span></label>
<label class="g-tile"><input type="radio" name="g-sel" class="g-radio" aria-label="4枚目の写真を大きく見る"><span class="g-tile__img g-tile__img--d"></span></label>
<label class="g-tile"><input type="radio" name="g-sel" class="g-radio" aria-label="5枚目の写真を大きく見る"><span class="g-tile__img g-tile__img--e"></span></label>
<label class="g-tile"><input type="radio" name="g-sel" class="g-radio" aria-label="6枚目の写真を大きく見る"><span class="g-tile__img g-tile__img--f"></span></label>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base; // 모프 전환 시간
$easing: $ease-spring;
$color: $subject-sky; // 포커스·선택 강조 색
@include stage($stage-ink);
.g-grid {
position: relative;
display: grid;
grid-template-columns: repeat(3, 56px);
grid-template-rows: repeat(2, 56px);
gap: $sp-2;
width: 184px;
height: 120px;
}
.g-tile {
position: relative;
display: block;
border-radius: $r-sm;
cursor: pointer;
transition: transform $duration $easing, opacity $duration $easing, box-shadow $duration $easing;
}
.g-tile:focus-within { outline: 2px solid $color; outline-offset: 2px; }
.g-radio { position: absolute; width: 1px; height: 1px; opacity: 0; }
.g-tile__img { display: block; width: 100%; height: 100%; border-radius: inherit; }
.g-tile__img--a { background: linear-gradient(155deg, $color 0%, $stage-ink 100%); }
.g-tile__img--b { background: linear-gradient(205deg, $subject-lilac 0%, $color 100%); }
.g-tile__img--c { background: linear-gradient(115deg, $color 0%, $subject-cream 55%); }
.g-tile__img--d { background: linear-gradient(250deg, $subject-pink 0%, $color 100%); }
.g-tile__img--e { background: linear-gradient(160deg, $subject-mint 0%, $color 100%); }
.g-tile__img--f { background: linear-gradient(200deg, $color 0%, $subject-pink 100%); }
// 모달·::backdrop 없이 그리드 자체가 재배치된다 — 라디오 하나가 켜지면 나머지는 옅어지고,
// 켜진 칸만 자기 위치에서 격자 전체 크기로 커진다 (칸마다 자리가 고정이라 이동량도 고정값)
.g-grid:has(.g-radio:checked) .g-tile:not(:has(.g-radio:checked)) { opacity: .22; transform: scale(.88); }
.g-grid .g-tile:has(.g-radio:checked) { z-index: 5; box-shadow: $shadow-raised; }
.g-grid .g-tile:nth-child(1):has(.g-radio:checked) { transform: translate(64px, 32px) scale(3.29, 2.14); }
.g-grid .g-tile:nth-child(2):has(.g-radio:checked) { transform: translate(0, 32px) scale(3.29, 2.14); }
.g-grid .g-tile:nth-child(3):has(.g-radio:checked) { transform: translate(-64px, 32px) scale(3.29, 2.14); }
.g-grid .g-tile:nth-child(4):has(.g-radio:checked) { transform: translate(64px, -32px) scale(3.29, 2.14); }
.g-grid .g-tile:nth-child(5):has(.g-radio:checked) { transform: translate(0, -32px) scale(3.29, 2.14); }
.g-grid .g-tile:nth-child(6):has(.g-radio:checked) { transform: translate(-64px, -32px) scale(3.29, 2.14); }
@media (prefers-reduced-motion: reduce) {
.g-tile { 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);
}
.g-grid {
position: relative;
display: grid;
grid-template-columns: repeat(3, 56px);
grid-template-rows: repeat(2, 56px);
gap: 8px;
width: 184px;
height: 120px;
}
.g-tile {
position: relative;
display: block;
border-radius: 8px;
cursor: pointer;
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1), opacity 300ms cubic-bezier(0.2, 0.8, 0.2, 1), box-shadow 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.g-tile:focus-within {
outline: 2px solid #6ec8ff;
outline-offset: 2px;
}
.g-radio {
position: absolute;
width: 1px;
height: 1px;
opacity: 0;
}
.g-tile__img {
display: block;
width: 100%;
height: 100%;
border-radius: inherit;
}
.g-tile__img--a {
background: linear-gradient(155deg, #6ec8ff 0%, #17141a 100%);
}
.g-tile__img--b {
background: linear-gradient(205deg, #b9a0ff 0%, #6ec8ff 100%);
}
.g-tile__img--c {
background: linear-gradient(115deg, #6ec8ff 0%, #fff7e6 55%);
}
.g-tile__img--d {
background: linear-gradient(250deg, #ff9ebb 0%, #6ec8ff 100%);
}
.g-tile__img--e {
background: linear-gradient(160deg, #4cd4a6 0%, #6ec8ff 100%);
}
.g-tile__img--f {
background: linear-gradient(200deg, #6ec8ff 0%, #ff9ebb 100%);
}
.g-grid:has(.g-radio:checked) .g-tile:not(:has(.g-radio:checked)) {
opacity: 0.22;
transform: scale(0.88);
}
.g-grid .g-tile:has(.g-radio:checked) {
z-index: 5;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.g-grid .g-tile:nth-child(1):has(.g-radio:checked) {
transform: translate(64px, 32px) scale(3.29, 2.14);
}
.g-grid .g-tile:nth-child(2):has(.g-radio:checked) {
transform: translate(0, 32px) scale(3.29, 2.14);
}
.g-grid .g-tile:nth-child(3):has(.g-radio:checked) {
transform: translate(-64px, 32px) scale(3.29, 2.14);
}
.g-grid .g-tile:nth-child(4):has(.g-radio:checked) {
transform: translate(64px, -32px) scale(3.29, 2.14);
}
.g-grid .g-tile:nth-child(5):has(.g-radio:checked) {
transform: translate(0, -32px) scale(3.29, 2.14);
}
.g-grid .g-tile:nth-child(6):has(.g-radio:checked) {
transform: translate(-64px, -32px) scale(3.29, 2.14);
}
@media (prefers-reduced-motion: reduce) {
.g-tile {
transition: none;
}
}(function () {
var frame = document.querySelector('.g-frame');
var radios = document.querySelectorAll('.g-radio');
function stopDemo() { frame.classList.remove('is-demo'); if (window.__gIv) clearInterval(window.__gIv); }
// 라디오는 같은 것을 다시 눌러도 기본으로는 안 꺼진다 — 눌렀을 때 이미 켜져 있으면 직접 끈다 (다시 클릭하면 격자로)
radios.forEach(function (r) {
r.addEventListener('pointerdown', function (e) {
stopDemo();
if (r.checked) { e.preventDefault(); r.checked = false; }
});
r.addEventListener('keydown', function (e) {
stopDemo();
if (e.key === 'Enter' || e.key === ' ') { if (r.checked) { e.preventDefault(); r.checked = false; } }
});
});
if (frame.classList.contains('is-demo')) {
var step = 0;
window.__gIv = setInterval(function () {
var i = Math.floor(step / 2) % radios.length;
radios[i].checked = step % 2 === 0;
step++;
}, 600);
}
})();import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./GridToSingleViewMorph.module.scss";
export type GridToSingleViewMorphProps = {
/** 모프 전환 시간 (기본 300ms) */
duration?: number;
easing?: string;
/** 선택 강조 색 */
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const TONES = ["a", "b", "c", "d", "e", "f"] as const;
// 칸마다 고정 위치라 "격자 전체 크기로 커질 때" 이동량도 고정값 — 3열×2행 격자에서 계산
const OFFSETS = [
"translate(64px, 32px) scale(3.29, 2.14)",
"translate(0, 32px) scale(3.29, 2.14)",
"translate(-64px, 32px) scale(3.29, 2.14)",
"translate(64px, -32px) scale(3.29, 2.14)",
"translate(0, -32px) scale(3.29, 2.14)",
"translate(-64px, -32px) scale(3.29, 2.14)",
];
/** 칸을 클릭하면 모달 없이 그리드 자체가 재배치되어 그 칸이 자리에서 커진다. 다시 클릭하면 되돌아간다 */
export default function GridToSingleViewMorph({
duration = 300,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#6ec8ff",
}: GridToSingleViewMorphProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const [selected, setSelected] = useState<number | null>(null);
return (
<div className={styles.grid} style={vars} role="radiogroup" aria-label="Pick a photo to view large">
{TONES.map((tone, i) => {
const isSelected = selected === i;
const dim = selected !== null && !isSelected;
return (
<button
key={i}
type="button"
role="radio"
aria-checked={isSelected}
aria-label={`View photo ${i + 1} large`}
className={`${styles.tile} ${dim ? styles.dim : ""} ${isSelected ? styles.selected : ""}`}
style={isSelected ? { transform: OFFSETS[i] } : undefined}
onClick={() => setSelected(isSelected ? null : i)}
>
<span className={`${styles.img} ${styles["img-" + tone]}`} />
</button>
);
})}
</div>
);
}09 · グリッド→単一ビュー モーフ切替 — 計測 累積面積 15.53% · 動いたフレーム 16/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
どこを変えるか
詳細パネルを持たない、写真そのもので見せるポートフォリオに使います。
.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予算を大きく超えていたのです。マスの上でカーソルが描く軌跡を追う構成はマウス カーソル エフェクト 9選で扱いました。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__imgにobject-fit: coverを付ければ、マスの伸びに関係なく写真自体は元の比率を保てます。動く素材を壁に掛けておく側はvideo player css の動画プレーヤー9選が受け持ちます。
html css フォト ギャラリーの06のドラッグはタッチスクリーンでも使えますか。
使えます。pointerdown・pointermove・pointerupはマウス・タッチ・ペンを1つのAPIでまとめて扱うので指でのドラッグも同じように動作し、各タイルにtouch-action: noneを指定してドラッグ中に画面ごとスクロールしてしまうのも防いでいます。









