<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Verve Studio — Selected Works | CodeKK</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,300..700;1,9..144,300..700&family=Instrument+Sans:wght@400..700&display=swap" rel="stylesheet">
<style>
:root {
--ink:#1B1712;
--paper:#F3EEE4;
--cream:#FBF8F0;
--accent:#BE4B28;
}
* { box-sizing: border-box; }
html, body {
margin: 0;
padding: 0;
}
body {
background: var(--paper);
color: var(--ink);
font-family: 'Instrument Sans', sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
img { max-width: 100%; display: block; }
button { font-family: inherit; cursor: pointer; background: none; }
/* Grain overlay */
.grain {
position: fixed;
inset: 0;
z-index: 70;
pointer-events: none;
opacity: .35;
mix-blend-mode: overlay;
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="180" height="180"><filter id="n"><feTurbulence type="fractalNoise" baseFrequency="0.82" numOctaves="2" stitchTiles="stitch"/><feColorMatrix type="saturate" values="0"/></filter><rect width="180" height="180" filter="url(%23n)" opacity="0.06"/></svg>');
}
/* Layout */
.main-container {
max-width: 1440px;
margin: 0 auto;
padding: 3rem 1.25rem;
}
@media (min-width: 640px) {
.main-container { padding: 4rem 2rem; }
}
@media (min-width: 1024px) {
.main-container { padding: 4rem 3rem; }
}
/* Header */
.header-row {
display: flex;
flex-direction: column;
justify-content: space-between;
align-items: flex-start;
gap: 1.5rem;
margin-bottom: 2.5rem;
}
@media (min-width: 640px) {
.header-row {
flex-direction: row;
align-items: flex-end;
}
}
.eyebrow {
display: flex;
align-items: center;
gap: .75rem;
font-size: 11px;
letter-spacing: 0.22em;
text-transform: uppercase;
color: rgba(27,23,18,.5);
margin-bottom: 1rem;
}
.eyebrow-line {
height: 1px;
width: 2rem;
background: var(--accent);
}
.page-title {
font-family: 'Fraunces', serif;
font-weight: 430;
font-size: clamp(2.8rem, 7vw, 4.8rem);
line-height: 1;
letter-spacing: -0.02em;
margin: 0;
}
.header-desc {
max-width: 24rem;
color: rgba(27,23,18,.6);
font-size: 15px;
margin: 0;
}
/* Filters */
.filters {
display: flex;
flex-wrap: wrap;
gap: .5rem;
margin-bottom: 2.25rem;
}
.filter-btn {
padding: .5rem 1.25rem;
border-radius: 9999px;
font-size: .875rem;
border: 1px solid rgba(27,23,18,.2);
color: var(--ink);
transition: all .3s ease;
}
.filter-btn:hover {
border-color: rgba(27,23,18,.4);
}
.filter-btn.active {
background: var(--ink);
color: var(--cream);
border-color: var(--ink);
}
/* Gallery grid */
.gallery {
display: grid;
grid-template-columns: repeat(1, minmax(0, 1fr));
gap: 1.5rem;
}
@media (min-width: 640px) {
.gallery { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (min-width: 1024px) {
.gallery { grid-template-columns: repeat(3, minmax(0, 1fr)); }
}
@media (min-width: 1280px) {
.gallery { grid-template-columns: repeat(4, minmax(0, 1fr)); }
}
.gallery-card {
position: relative;
overflow: hidden;
border-radius: 1rem;
aspect-ratio: 1 / 1;
cursor: pointer;
background: var(--ink);
}
.gallery-img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform .6s cubic-bezier(.22,1,.36,1), box-shadow .4s ease;
}
.gallery-card:hover .gallery-img {
transform: scale(1.04);
box-shadow: 0 30px 70px -20px rgba(27,23,18,.45);
}
.gallery-overlay {
position: absolute;
inset: 0;
background: linear-gradient(to bottom, rgba(0,0,0,.1), rgba(0,0,0,.3), rgba(0,0,0,.7));
}
.project-label {
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: 1.5rem;
transform: translateY(0.5rem);
opacity: .9;
transition: transform .4s cubic-bezier(.22,1,.36,1), opacity .3s ease;
}
.gallery-card:hover .project-label {
transform: translateY(0);
opacity: 1;
}
.project-title {
font-weight: 500;
color: var(--cream);
font-size: 1.125rem;
letter-spacing: -0.01em;
margin: 0;
}
.project-desc {
color: rgba(251,248,240,.7);
font-size: .875rem;
margin: 0;
}
/* Modal */
.modal {
display: none;
position: fixed;
inset: 0;
z-index: 100;
background: rgba(0,0,0,.9);
align-items: center;
justify-content: center;
padding: 1rem;
}
.modal.open {
display: flex;
animation: fadeInModal .3s cubic-bezier(.22,1,.36,1);
}
@keyframes fadeInModal {
from { opacity: 0; transform: scale(0.96); }
to { opacity: 1; transform: scale(1); }
}
.modal-inner {
position: relative;
max-width: 64rem;
width: 100%;
}
.modal-close {
position: absolute;
top: -0.75rem;
right: -0.75rem;
z-index: 10;
width: 2.5rem;
height: 2.5rem;
background: var(--ink);
color: var(--cream);
border-radius: 9999px;
display: flex;
align-items: center;
justify-content: center;
transition: background-color .2s ease;
border: none;
}
.modal-close:hover {
background: var(--accent);
}
.modal-close svg {
width: 1.25rem;
height: 1.25rem;
}
.modal-image {
width: 100%;
max-height: 85vh;
object-fit: contain;
border-radius: 1rem;
}
.modal-footer {
margin-top: 1rem;
display: flex;
justify-content: space-between;
align-items: center;
color: var(--cream);
padding: 0 .25rem;
}
.modal-title {
font-weight: 500;
font-size: 1.125rem;
margin: 0;
}
.modal-category {
font-size: .875rem;
color: rgba(251,248,240,.6);
margin: 0;
}
.modal-nav {
display: flex;
gap: .5rem;
}
.modal-nav-btn {
padding: .5rem 1rem;
border: 1px solid rgba(251,248,240,.3);
border-radius: 9999px;
color: var(--cream);
transition: background-color .2s ease;
}
.modal-nav-btn:hover {
background: rgba(255,255,255,.1);
}
</style>
</head>
<body>
<div class="grain" aria-hidden="true"></div>
<main class="main-container">
<!-- Header -->
<div class="header-row">
<div>
<div class="eyebrow">
<span class="eyebrow-line"></span>
<span>Project Archive</span>
</div>
<h1 class="page-title">
Selected Works
</h1>
</div>
<p class="header-desc">
A curated collection of projects we’ve had the privilege to shape over the last five years.
</p>
</div>
<!-- Filters -->
<div class="filters" id="filters">
<button data-filter="all" class="filter-btn active">All</button>
<button data-filter="web" class="filter-btn">Web Design</button>
<button data-filter="brand" class="filter-btn">Branding</button>
<button data-filter="print" class="filter-btn">Print</button>
<button data-filter="digital" class="filter-btn">Digital</button>
</div>
<!-- Gallery Grid -->
<div class="gallery" id="gallery">
<!-- Populated by JavaScript -->
</div>
</main>
<!-- Lightbox Modal -->
<div id="modal" class="modal" onclick="closeModal()">
<div onclick="event.stopImmediatePropagation()" class="modal-inner">
<!-- Close button -->
<button onclick="closeModal()" class="modal-close">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6h12v12" />
</svg>
</button>
<img id="modal-image" class="modal-image" alt="">
<div class="modal-footer">
<div>
<p id="modal-title" class="modal-title"></p>
<p id="modal-category" class="modal-category"></p>
</div>
<div class="modal-nav">
<button onclick="prevImage()" class="modal-nav-btn">← Prev</button>
<button onclick="nextImage()" class="modal-nav-btn">Next →</button>
</div>
</div>
</div>
</div>
<script>
// Gallery Data
const projects = [
{ id:1, title:"Halcyon Labs", category:"web", img:"https://picsum.photos/id/1015/800/800", desc:"Web Design" },
{ id:2, title:"Northwind Supply", category:"brand", img:"https://picsum.photos/id/1005/800/800", desc:"Branding" },
{ id:3, title:"Oktav Identity", category:"brand", img:"https://picsum.photos/id/201/800/800", desc:"Branding" },
{ id:4, title:"Blinkfold Platform", category:"web", img:"https://picsum.photos/id/160/800/800", desc:"Web Design" },
{ id:5, title:"Marrow Annual Report", category:"print", img:"https://picsum.photos/id/251/800/800", desc:"Print" },
{ id:6, title:"Vitale & Co.", category:"digital", img:"https://picsum.photos/id/29/800/800", desc:"Digital" },
{ id:7, title:"Sønne Campaign", category:"digital", img:"https://picsum.photos/id/180/800/800", desc:"Digital" },
{ id:8, title:"Field Notes 2024", category:"print", img:"https://picsum.photos/id/133/800/800", desc:"Print" },
{ id:9, title:"Arcade Studio", category:"web", img:"https://picsum.photos/id/106/800/800", desc:"Web Design" },
{ id:10, title:"Lumen Typeface", category:"brand", img:"https://picsum.photos/id/312/800/800", desc:"Branding" },
{ id:11, title:"Aether Packaging", category:"print", img:"https://picsum.photos/id/48/800/800", desc:"Print" },
{ id:12, title:"Pulse Dashboard", category:"web", img:"https://picsum.photos/id/177/800/800", desc:"Web Design" },
];
let currentFilter = "all";
let currentIndex = 0;
let filteredProjects = [...projects];
// Render Gallery
function renderGallery(filter = "all") {
const container = document.getElementById("gallery");
container.innerHTML = "";
filteredProjects = filter === "all" ? projects : projects.filter(p => p.category === filter);
filteredProjects.forEach((project, index) => {
const card = document.createElement("div");
card.className = "gallery-card";
card.innerHTML = `
<img src="${project.img}" alt="${project.title}" class="gallery-img">
<div class="gallery-overlay"></div>
<div class="project-label">
<p class="project-title">${project.title}</p>
<p class="project-desc">${project.desc}</p>
</div>
`;
card.onclick = () => openModal(index);
container.appendChild(card);
});
}
// Filter Buttons
function setupFilters() {
const buttons = document.querySelectorAll("#filters button");
buttons.forEach(btn => {
btn.addEventListener("click", () => {
// Update active state
buttons.forEach(b => b.classList.remove("active"));
btn.classList.add("active");
const filter = btn.getAttribute("data-filter");
currentFilter = filter;
renderGallery(filter);
});
});
}
// Lightbox Modal
let currentModalIndex = 0;
function openModal(index) {
currentModalIndex = index;
const modal = document.getElementById("modal");
const img = document.getElementById("modal-image");
const title = document.getElementById("modal-title");
const cat = document.getElementById("modal-category");
const project = filteredProjects[index];
img.src = project.img;
title.textContent = project.title;
cat.textContent = project.desc;
modal.classList.add("open");
document.body.style.overflow = "hidden";
}
function closeModal() {
const modal = document.getElementById("modal");
modal.classList.remove("open");
document.body.style.overflow = "";
}
function nextImage() {
currentModalIndex = (currentModalIndex + 1) % filteredProjects.length;
updateModalImage();
}
function prevImage() {
currentModalIndex = (currentModalIndex - 1 + filteredProjects.length) % filteredProjects.length;
updateModalImage();
}
function updateModalImage() {
const project = filteredProjects[currentModalIndex];
const img = document.getElementById("modal-image");
const title = document.getElementById("modal-title");
const cat = document.getElementById("modal-category");
img.src = project.img;
title.textContent = project.title;
cat.textContent = project.desc;
}
// Keyboard support for modal
document.addEventListener("keydown", (e) => {
const modal = document.getElementById("modal");
if (!modal.classList.contains("open")) return;
if (e.key === "Escape") closeModal();
if (e.key === "ArrowRight") nextImage();
if (e.key === "ArrowLeft") prevImage();
});
// Initialize
function init() {
renderGallery();
setupFilters();
}
init();
</script>
</body>
</html>Component details
Overview, install command, dependencies, and project links.
A warm-toned creative portfolio gallery built with pure HTML, CSS, and vanilla JavaScript - no frameworks, no libraries. Features a category filter bar that shows and hides cards instantly, a 4-column responsive grid that scales from 1 column on mobile to 4 on large screens, and a lightbox modal that opens on card click with prev/next navigation and keyboard support. Each gallery card scales the image on hover and slides up a title and category label from the bottom. The modal closes on overlay click, close button click, or the Escape key. Arrow keys navigate between images inside the modal. Gallery data lives in a single JavaScript array at the top of the script - adding a new project is one object. Uses Fraunces serif for headings and Instrument Sans for body text. A CSS grain overlay adds texture using an inline SVG filter. Single HTML file, no build step, open in browser and it works.
Discussion
Feedback, implementation tips, and usage notes.
Comments
Sign in to join the conversation
