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
- 01 Infinite-scroll photo wall
- 02 Polaroid stack gallery
- 03 Hover-zoom product gallery
- 04 Video thumbnail grid with play overlay
- 05 Tag-filterable portfolio grid
- 06 Drag-to-reorder photo grid
- 07 Full-bleed slideshow with thumbnail strip
- 08 Scrim caption reveal gallery
- 09 Grid-to-single-view morph transition
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.
.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.
.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.
.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.
.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.
.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.
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.
.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.
.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.
.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.