GODRICH
#css-animation#button#loader#transition#scroll#dashboard#ui#design-trends

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 the image gallery layout (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). Stacking two photos under a draggable divider is 9 Before After Slider UIs, and zooming with wheel and lens alone is 9 Image Zoom Viewer Interactions.

# 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

What this photo gallery layout shows

Small square photos scroll sideways without end.

Infinite-scroll photo wall
Open in a new tab

01 · Infinite-scroll photo wall — Measured area 8.037% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

translateX(-50%) 무한 루프track 통째 복제margin-right 간격

Where to look in the code

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.

.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

What happens on screen

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.

Polaroid stack gallery
Open in a new tab

02 · Polaroid stack gallery — Measured area 29.485% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

rotate() 각도 오프셋z-index 스택transform 순환 키프레임

How to read the code

It suits a travel album you'd otherwise flip through page by page.

.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

What moves

Hovering a product tile zooms only the photo inside its fixed frame while a price bar slides up from the bottom edge.

Hover-zoom product gallery
Open in a new tab

03 · Hover-zoom product gallery — Measured area 5.825% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

overflow:hidden 프레임내부 이미지만 scaletranslateY 가격바

What to change

It fits a shop listing that would rather reveal price on hover than print it upfront.

.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

What you see

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.

Video thumbnail grid with play overlay
Open in a new tab

04 · Video thumbnail grid with play overlay — Measured area 4.782% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

재생 아이콘 scaleripple 확산 1회재생시간 배지

Where to look in the code

It works well for a media page listing a handful of clips.

.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

What happens on screen

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.

Tag-filterable portfolio grid
Open in a new tab

05 · Tag-filterable portfolio grid — Measured area 19.24% · moving frames 11/23Original by GODRICH · SCSS and React ship in the zip · See the password

:has(:checked) 필터체크박스 필터칩opacity+scale 감춤

How to read the code

It fits an agency page splitting work into web, app, and branding.

.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

What moves

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.

Drag-to-reorder photo grid
Open in a new tab

06 · Drag-to-reorder photo grid — Measured area 7.467% · moving frames 17/23Original by GODRICH · SCSS and React ship in the zip · See the password

pointerdown/move/uptransform 스왑키보드 화살표 이동

What to change

It suits a product-image manager where the merchant picks the hero shot.

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

What you see

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.

Full-bleed slideshow with thumbnail strip
Open in a new tab

07 · Full-bleed slideshow with thumbnail strip — Measured area 20.474% · moving frames 6/23Original by GODRICH · SCSS and React ship in the zip · See the password

1번 슬라이드 클론translateX 트랙썸네일 active 테두리

Where to look in the code

It fits a portfolio hero cycling through a handful of signature pieces.

.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

What happens on screen

Photos show plain until hover, when a dark scrim spreads across the whole image and the caption fades up in the center.

Scrim caption reveal gallery
Open in a new tab

08 · Scrim caption reveal gallery — Measured area 17.063% · moving frames 12/23Original by GODRICH · SCSS and React ship in the zip · See the password

radial-gradient 스크림opacity 페이드업translateY(6px) 텍스트

How to read the code

Unlike a band that slides up from the edge, the whole photo dims here, which keeps the photo itself readable underneath.

.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

What moves

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.

Grid-to-single-view morph transition
Open in a new tab

09 · Grid-to-single-view morph transition — Measured area 15.53% · moving frames 16/23Original by GODRICH · SCSS and React ship in the zip · See the password

:checked 상태 분기transform: translate+scalez-index 승격

What to change

It suits a photo-first portfolio with no detail panel to load.

.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 unLayouts that chase the trail a cursor draws across the tiles are handled in 9 Mouse Effects CSS Tricks. The vanilla folder, locked 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. Hanging moving material on the wall is the job of 9 Custom Video Player CSS Controls.

In this portfolio gallery design, 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.