GODRICH

Portfolio Gallery HTML CSS: 9 UI Patterns

A portfolio gallery html css layout arranges photos in a grid that zooms, filters, or morphs into a single view on hover or click — this is the portfolio

Auto-plays · click a tile to jump to its section · all nine in one zip

The order below is not popularity — it follows how someone actually encounters a gallery. First they scroll past it (01), then flip through it by hand (02), pick one photo to zoom into (03), look for a play button if it's video (04), filter by tag when there's too much to scan (05), reorder it themselves if they own it (06), skim a handful of featured pieces (07), read a caption out of curiosity (08), and finally settle into one photo full-size (09).

# Name What moves JS
01 Infinite-scroll photo wall track translateX none
02 Polaroid stack gallery card rotate·translate none
03 Hover-zoom product gallery photo scale, price bar translateY none
04 Video thumbnail grid with play overlay button scale, ring scale·opacity none
05 Tag-filterable portfolio grid card opacity·scale 13 lines (autoplay only)
06 Drag-to-reorder photo grid tile translateX 34 lines
07 Full-bleed slideshow with thumbnail strip track translateX, thumb border 19 lines
08 Scrim caption reveal gallery scrim opacity, caption translateY none
09 Grid-to-single-view morph transition tile translate·scale 24 lines

01Infinite-scroll photo wall

Small square photos scroll sideways without end. The whole strip is duplicated and appended once, so the moment the track has moved exactly half its own width (-50%), the screen looks identical to frame one and the seam is invisible.

translateX(-50%) 무한 루프track 통째 복제margin-right 간격
.wall-track {
  display: flex;
  width: max-content;              // 5 photos × 2 copies = 10 tiles
  animation: wall-loop $duration linear infinite;
}
.wall-tile {
  flex: 0 0 auto;
  margin-right: $sp-2;             // margin, not gap — gap throws off the -50% math
}
@keyframes wall-loop {
  0%   { transform: translateX(0); }
  100% { transform: translateX(-50%); }
}

02Polaroid stack gallery

Four polaroid-style photos sit stacked at slightly different angles, and the top one slides aside and rotates away to reveal the next, cycling through in order. It suits a travel album you'd otherwise flip through page by page.

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; }
}

03Hover-zoom product gallery

Hovering a product tile zooms only the photo inside its fixed frame while a price bar slides up from the bottom edge. It fits a shop listing that would rather reveal price on hover than print it upfront.

overflow:hidden 프레임내부 이미지만 scaletranslateY 가격바
.pcard { overflow: hidden; }        // the frame size never changes
.pcard__img {
  transition: transform $duration $easing;
}
.pcard__price {
  transform: translateY(100%);      // hidden below the edge at rest
  transition: transform $duration $easing;
}
.pcard:hover .pcard__img { transform: scale(1.14); }
.pcard:hover .pcard__price { transform: translateY(0); }

04Video thumbnail grid with play overlay

Each video-thumbnail tile has a centered play button that grows on hover, while a ripple ring keeps pulsing around it on a steady loop to flag the tile as a playable clip. It works well for a media page listing a handful of clips.

재생 아이콘 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; }
}

05Tag-filterable portfolio grid

Each project card carries a category tag, and clicking a filter chip above dims and shrinks the cards that don't match — using only the CSS :has() selector, no JavaScript. It fits an agency page splitting work into web, app, and branding.

: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);
}

06Drag-to-reorder photo grid

Press and drag a photo tile toward a neighbor and the two swap places; releasing settles the rest of the grid into its new order. It suits a product-image manager where the merchant picks the hero shot.

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;
});

07Full-bleed slideshow with thumbnail strip

One large photo fills the full width and advances on its own while a thumbnail strip below lights up the border of whichever slide is showing. It fits a portfolio hero cycling through a handful of signature pieces.

1번 슬라이드 클론translateX 트랙썸네일 active 테두리
.ss-track {
  width: 500%;                       // 4 real slides + a clone of slide 1 = 5 tiles
  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%); }   // the clone sits where slide 1 sits
}

08Scrim caption reveal gallery

Photos show plain until hover, when a dark scrim spreads across the whole image and the caption fades up in the center. Unlike a band that slides up from the edge, the whole photo dims here, which keeps the photo itself readable underneath.

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); }

09Grid-to-single-view morph transition

Clicking a tile doesn't open a modal — the grid itself re-arranges: the chosen tile grows in place while the rest dim and step aside. It suits a photo-first portfolio with no detail panel to load.

:checked 상태 분기transform: translate+scalez-index 승격
.g-grid:has(.g-radio:checked) .g-tile:not(:has(.g-radio:checked)) {
  opacity: .22;
  transform: scale(.88);
}
// each tile's grid position is fixed, so its "grow" offset is a fixed value too
.g-grid .g-tile:nth-child(1):has(.g-radio:checked) {
  transform: translate(64px, 32px) scale(3.29, 2.14);
}

Where this breaks — one trap

Rendering 05 (tag filter) and 07 (thumbnail strip) at phone width (320px) for the first time cut content off vertically. Both demos only shrank card width with something like min(240px, 82vw) — the viewport got narrower, but the row count (2) and card ratio (4:3) stayed fixed, so the actual height blew past the 200px phone budget. Inside the vanilla folder unlocked by the zip password bhpgj3rm, 05 was reflowed from two rows into one row of four narrower, taller (3:4) cards, and 07 had its whole width trimmed once more to 200px before it fit. A min()/vw layout that only tracks width needs a separate height check.

Accessibility

With reduced motion turned on (prefers-reduced-motion: reduce), all nine keep their transitions and loops off while still landing on the finished state. 01's track simply stops on its first frame, 02·04·08 sit at rest with no cycling animation, 03·06·09 apply their zoom or move instantly, and 07's slide swaps without a transition. 06's tiles reorder with the left and right arrow keys as well as a pointer drag, and 09's tiles are native radio buttons, so Tab and the arrow keys alone pick a photo to view large. See the MDN Pointer Events reference and MDN :has() for browser support. Two neighboring posts in this gallery series are worth a look too: 9 CSS Image Gallery Grid Effects and 9 Before After Slider UIs.

FAQ

Does the :has() filter in 05 work in every browser?

Chrome, Edge, and Safari have supported it for a while, and Firefox added it in version 121. In an older browser that doesn't understand the selector, it's simply ignored — the filter stops working but every card stays visible, so the layout never breaks.

Why does the photo in 09 look stretched instead of keeping its original ratio?

Because the tile scales by different amounts on each axis (scale(3.29, 2.14)) to fill a wide grid area from a square cell. When you swap in a real photo, add object-fit: cover to .g-tile__img so the photo itself keeps its own aspect ratio regardless of the tile's stretch.

Does the drag in 06 work on a touchscreen?

Yes. pointerdown, pointermove, and pointerup cover mouse, touch, and pen through one API, so a finger drag behaves the same way, and touch-action: none on each tile stops the page from scrolling underneath the drag.

Enter the archive password

The password is inside this article. You will find it as you read.