Skip to content

Instantly share code, notes, and snippets.

@SakuraRinDev
Created January 6, 2026 03:55
Show Gist options
  • Select an option

  • Save SakuraRinDev/d1296816f506c0c85a15c8d629e2b240 to your computer and use it in GitHub Desktop.

Select an option

Save SakuraRinDev/d1296816f506c0c85a15c8d629e2b240 to your computer and use it in GitHub Desktop.
[gsap] ❍ Draggable Image Gallery
<!-- Preloader -->
<div id="preloader-overlay"></div>
<!-- Header Navigation -->
<div class="header">
<div class="nav-section">
<div class="logo-container">
<div class="logo-circles">
<div class="circle circle-1"></div>
<div class="circle circle-2"></div>
</div>
</div>
</div>
<div class="values-section">
<h3>+Menu</h3>
<ul>
<li><a href="#">Work</a></li>
<li><a href="#">About</a></li>
<li><a href="#">Services</a></li>
<li><a href="#">Contact</a></li>
</ul>
</div>
<div class="location-section">
<h3>+Studio</h3>
<p>Los Angeles</p>
<p>California</p>
</div>
<div class="contact-section">
<h3>+Connect</h3>
<p><a href="mailto:hi@filip.fyi">hi@filip.fyi</a></p>
</div>
<div class="social-section">
<h3>+Follow</h3>
<ul>
<li><a href="https://instagram.com/filipz__">Instagram</a></li>
<li><a href="https://x.com/filipz">Twitter</a></li>
<li><a href="https://linkedin.com/in/filipzrnzevic">LinkedIn</a></li>
</ul>
</div>
</div>
<!-- Main Viewport -->
<div class="viewport" id="viewport">
<div class="canvas-wrapper" id="canvasWrapper">
<div class="grid-container" id="gridContainer">
<!-- Grid items will be generated by JavaScript -->
</div>
</div>
</div>
<!-- Split Screen Container -->
<div class="split-screen-container" id="splitScreenContainer">
<div class="split-left" id="splitLeft">
<div class="zoom-target" id="zoomTarget"></div>
</div>
<div class="split-right" id="splitRight">
<!-- Right panel content -->
</div>
</div>
<!-- Image Title Overlay -->
<div class="image-title-overlay" id="imageTitleOverlay">
<div class="image-slide-number" id="imageSlideNumber">
<span>01</span>
</div>
<div class="image-slide-title" id="imageSlideTitle">
<h1>Fashion Portrait</h1>
</div>
<div class="image-slide-description" id="imageSlideDescription">
<!-- Lines will be generated dynamically -->
</div>
</div>
<!-- Simple 64px white arrow button -->
<button class="close-button" id="closeButton">
<svg width="64" height="64" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M7.89873 16L6.35949 14.48L11.8278 9.08H0V6.92H11.8278L6.35949 1.52L7.89873 0L16 8L7.89873 16Z" fill="white" />
</svg>
</button>
<!-- Controls Container -->
<div class="controls-container" id="controlsContainer">
<div class="percentage-indicator" id="percentageIndicator">
60%
</div>
<div class="switch" id="controls">
<button class="switch-button" onclick="gallery.setZoom(0.3, this)">
<span class="indicator-dot"></span>
ZOOM OUT
</button>
<button class="switch-button switch-button-current" onclick="gallery.setZoom(0.6, this)">
<span class="indicator-dot"></span>
NORMAL
</button>
<button class="switch-button" onclick="gallery.setZoom(1.0, this)">
<span class="indicator-dot"></span>
ZOOM IN
</button>
<button class="switch-button" onclick="gallery.autoFitZoom(this)">
<span class="indicator-dot"></span>
FIT
</button>
</div>
<button class="sound-toggle" id="soundToggle">
<canvas class="sound-wave-canvas" id="soundWaveCanvas" width="32" height="16"></canvas>
</button>
</div>
<!-- Footer Navigation -->
<div class="footer">
<div class="info-section">
<p>Est. 2025 • Summer Days</p>
<p>34.0522° N, 118.2437° W</p>
</div>
</div>
<!-- Page vignette effects -->
<div class="page-vignette-container">
<div class="page-vignette-extreme"></div>
</div>
// Register GSAP plugins
gsap.registerPlugin(Draggable, InertiaPlugin, CustomEase, Flip);
class PreloaderManager {
constructor() {
this.overlay = null;
this.canvas = null;
this.ctx = null;
this.animationId = null;
this.startTime = null;
this.duration = 2000; // 2 seconds
this.createLoadingScreen();
}
createLoadingScreen() {
this.overlay = document.getElementById("preloader-overlay");
this.overlay.style.cssText = `
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: #000;
display: flex;
justify-content: center;
align-items: center;
z-index: 100000;
`;
this.canvas = document.createElement("canvas");
this.canvas.width = 300;
this.canvas.height = 300;
this.ctx = this.canvas.getContext("2d");
this.overlay.appendChild(this.canvas);
this.startAnimation();
}
startAnimation() {
const centerX = this.canvas.width / 2;
const centerY = this.canvas.height / 2;
let time = 0;
let lastTime = 0;
const dotRings = [
{ radius: 20, count: 8 },
{ radius: 35, count: 12 },
{ radius: 50, count: 16 },
{ radius: 65, count: 20 },
{ radius: 80, count: 24 }
];
const colors = {
primary: "#2C1B14",
accent: "#A64B23"
};
const hexToRgb = (hex) => {
return [
parseInt(hex.slice(1, 3), 16),
parseInt(hex.slice(3, 5), 16),
parseInt(hex.slice(5, 7), 16)
];
};
const animate = (timestamp) => {
if (!this.startTime) this.startTime = timestamp;
if (!lastTime) lastTime = timestamp;
const deltaTime = timestamp - lastTime;
lastTime = timestamp;
time += deltaTime * 0.001;
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
// Draw center dot
this.ctx.beginPath();
this.ctx.arc(centerX, centerY, 3, 0, Math.PI * 2);
const rgb = hexToRgb(colors.primary);
this.ctx.fillStyle = `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, 0.9)`;
this.ctx.fill();
// Draw Line Pulse Wave animation
dotRings.forEach((ring, ringIndex) => {
for (let i = 0; i < ring.count; i++) {
const angle = (i / ring.count) * Math.PI * 2;
const radiusPulse = Math.sin(time * 2 - ringIndex * 0.4) * 3;
const x = centerX + Math.cos(angle) * (ring.radius + radiusPulse);
const y = centerY + Math.sin(angle) * (ring.radius + radiusPulse);
const opacityWave =
0.4 + Math.sin(time * 2 - ringIndex * 0.4 + i * 0.2) * 0.6;
const isActive = Math.sin(time * 2 - ringIndex * 0.4 + i * 0.2) > 0.6;
// Draw line from center to point
this.ctx.beginPath();
this.ctx.moveTo(centerX, centerY);
this.ctx.lineTo(x, y);
this.ctx.lineWidth = 0.8;
if (isActive) {
const accentRgb = hexToRgb(colors.accent);
this.ctx.strokeStyle = `rgba(${accentRgb[0]}, ${accentRgb[1]}, ${
accentRgb[2]
}, ${opacityWave * 0.7})`;
} else {
const primaryRgb = hexToRgb(colors.primary);
this.ctx.strokeStyle = `rgba(${primaryRgb[0]}, ${primaryRgb[1]}, ${
primaryRgb[2]
}, ${opacityWave * 0.5})`;
}
this.ctx.stroke();
// Draw dot at the end of the line
this.ctx.beginPath();
this.ctx.arc(x, y, 2.5, 0, Math.PI * 2);
if (isActive) {
const accentRgb = hexToRgb(colors.accent);
this.ctx.fillStyle = `rgba(${accentRgb[0]}, ${accentRgb[1]}, ${accentRgb[2]}, ${opacityWave})`;
} else {
const primaryRgb = hexToRgb(colors.primary);
this.ctx.fillStyle = `rgba(${primaryRgb[0]}, ${primaryRgb[1]}, ${primaryRgb[2]}, ${opacityWave})`;
}
this.ctx.fill();
}
});
// Check if we should complete the loading
if (timestamp - this.startTime >= this.duration) {
this.complete();
return;
}
this.animationId = requestAnimationFrame(animate);
};
this.animationId = requestAnimationFrame(animate);
}
complete(onComplete) {
if (this.animationId) {
cancelAnimationFrame(this.animationId);
}
if (this.overlay) {
this.overlay.style.opacity = "0";
this.overlay.style.transition = "opacity 0.8s ease";
setTimeout(() => {
this.overlay?.remove();
if (onComplete) onComplete();
}, 800);
}
}
}
class FashionGallery {
constructor() {
// DOM elements
this.viewport = document.getElementById("viewport");
this.canvasWrapper = document.getElementById("canvasWrapper");
this.gridContainer = document.getElementById("gridContainer");
this.splitScreenContainer = document.getElementById("splitScreenContainer");
this.imageTitleOverlay = document.getElementById("imageTitleOverlay");
this.closeButton = document.getElementById("closeButton");
this.controlsContainer = document.getElementById("controlsContainer");
this.soundToggle = document.getElementById("soundToggle");
// Create custom eases
this.customEase = CustomEase.create("smooth", ".87,0,.13,1");
this.centerEase = CustomEase.create("center", ".25,.46,.45,.94");
// Configuration
this.config = {
itemSize: 320,
baseGap: 16,
rows: 8,
cols: 12,
currentZoom: 0.6,
currentGap: 32
};
// State
this.zoomState = {
isActive: false,
selectedItem: null,
flipAnimation: null,
scalingOverlay: null
};
this.gridItems = [];
this.gridDimensions = {};
this.lastValidPosition = {
x: 0,
y: 0
};
this.draggable = null;
this.viewportObserver = null;
// Initialize sound system
this.initSoundSystem();
// Initialize image data
this.initImageData();
}
initSoundSystem() {
this.soundSystem = {
enabled: false,
sounds: {
click: new Audio("https://assets.codepen.io/7558/glitch-fx-001.mp3"),
open: new Audio("https://assets.codepen.io/7558/click-glitch-001.mp3"),
close: new Audio("https://assets.codepen.io/7558/click-glitch-001.mp3"),
"zoom-in": new Audio(
"https://assets.codepen.io/7558/whoosh-fx-001.mp3"
),
"zoom-out": new Audio(
"https://assets.codepen.io/7558/whoosh-fx-001.mp3"
),
"drag-start": new Audio(
"https://assets.codepen.io/7558/preloader-2s-001.mp3"
),
"drag-end": new Audio(
"https://assets.codepen.io/7558/preloader-2s-001.mp3"
)
},
play: (soundName) => {
if (!this.soundSystem.enabled || !this.soundSystem.sounds[soundName])
return;
try {
const audio = this.soundSystem.sounds[soundName];
audio.currentTime = 0;
audio.play().catch(() => {});
} catch (e) {
// Silently handle audio errors
}
},
toggle: () => {
this.soundSystem.enabled = !this.soundSystem.enabled;
this.soundToggle.classList.toggle("active", this.soundSystem.enabled);
// Prevent visual conflicts during sound toggle
if (this.zoomState.isActive) return;
if (this.soundSystem.enabled) {
// Delay sound to prevent flashing during visual updates
setTimeout(() => {
this.soundSystem.play("click");
}, 50);
}
}
};
// Preload sounds
Object.values(this.soundSystem.sounds).forEach((audio) => {
audio.preload = "auto";
audio.volume = 0.3;
});
// Initialize sound wave canvas animation
this.initSoundWave();
}
initSoundWave() {
const canvas = document.getElementById("soundWaveCanvas");
if (!canvas) return;
const ctx = canvas.getContext("2d");
const width = 32;
const height = 16;
const centerY = Math.floor(height / 2);
let startTime = Date.now();
let currentAmplitude = this.soundSystem.enabled ? 1 : 0;
const interpolateColor = (color1, color2, factor) => {
const r1 = parseInt(color1.substring(1, 3), 16);
const g1 = parseInt(color1.substring(3, 5), 16);
const b1 = parseInt(color1.substring(5, 7), 16);
const r2 = parseInt(color2.substring(1, 3), 16);
const g2 = parseInt(color2.substring(3, 5), 16);
const b2 = parseInt(color2.substring(5, 7), 16);
const r = Math.round(r1 + factor * (r2 - r1))
.toString(16)
.padStart(2, "0");
const g = Math.round(g1 + factor * (g2 - g1))
.toString(16)
.padStart(2, "0");
const b = Math.round(b1 + factor * (b2 - b1))
.toString(16)
.padStart(2, "0");
return `#${r}${g}${b}`;
};
const animate = () => {
const targetAmplitude = this.soundSystem.enabled ? 1 : 0;
currentAmplitude += (targetAmplitude - currentAmplitude) * 0.08;
ctx.clearRect(0, 0, width, height);
const time = (Date.now() - startTime) / 1000;
const muteFactor = 1 - currentAmplitude;
const primaryColor = "#2C1B14";
const accentColor = "#A64B23";
const muteColor = "#D9C4AA";
if (!this.soundSystem.enabled && currentAmplitude < 0.01) {
ctx.fillStyle = muteColor;
ctx.fillRect(0, centerY, width, 2);
} else {
ctx.fillStyle = interpolateColor(primaryColor, muteColor, muteFactor);
for (let i = 0; i < width; i++) {
const x = i - width / 2;
const e = Math.exp((-x * x) / 50);
const y =
centerY +
Math.cos(x * 0.4 - time * 8) * e * height * 0.35 * currentAmplitude;
ctx.fillRect(i, Math.round(y), 1, 2);
}
ctx.fillStyle = interpolateColor(accentColor, muteColor, muteFactor);
for (let i = 0; i < width; i++) {
const x = i - width / 2;
const e = Math.exp((-x * x) / 80);
const y =
centerY +
Math.cos(x * 0.3 - time * 5) * e * height * 0.25 * currentAmplitude;
ctx.fillRect(i, Math.round(y), 1, 2);
}
}
requestAnimationFrame(animate);
};
animate();
}
initImageData() {
// Fashion portrait images
this.fashionImages = [];
for (let i = 1; i <= 14; i++) {
const paddedNumber = String(i).padStart(2, "0");
this.fashionImages.push(
`https://assets.codepen.io/7558/orange-portrait_${paddedNumber}.jpg`
);
}
// Image data for titles and descriptions
this.imageData = [
{
number: "01",
title: "Begin Before You’re Ready",
description:
"The work starts when you notice the quiet pull. Breathe once, clear the room inside you, and move one pixel forward."
},
{
number: "02",
title: "Negative Space, Positive Signal",
description:
"Leave room around the idea. In the silence, the design answers back and shows you what to remove."
},
{
number: "03",
title: "Friction Is a Teacher",
description:
"When the line resists, listen. Constraints are coordinates—plot them, then chart a cleaner route."
},
{
number: "04",
title: "Golden Minute",
description:
"Catch the light while it’s honest. One honest frame beats a hundred almosts."
},
{
number: "05",
title: "Shadow Carries Form",
description:
"The dark reveals the edge. Let contrast articulate what you mean but can’t yet say."
},
{
number: "06",
title: "City Breath",
description:
"Steel, glass, heartbeat. Edit until the street’s rhythm fits inside a single grid."
},
{
number: "07",
title: "Soft Focus, Sharp Intent",
description:
"Blur the noise, not the purpose. What matters remains in crisp relief."
},
{
number: "08",
title: "Time-Tested, Future-Ready",
description:
"Classics survive because they serve. Keep the spine, tune the surface, respect the lineage."
},
{
number: "09",
title: "Grace Under Revision",
description:
"Drafts don’t apologize. They evolve. Let elegance emerge through cuts, not flourishes."
},
{
number: "10",
title: "Style That Outlasts Seasons",
description:
"Trends talk. Principles walk. Build on principles and let trends accessorize."
},
{
number: "11",
title: "Edges and Experiments",
description:
"Push just past comfort. Leave a fingerprint the algorithm can’t fake."
},
{
number: "12",
title: "Portrait of Attention",
description:
"Form is what you see. Presence is what you feel. Aim for presence."
},
{
number: "13",
title: "Light Speaks First",
description:
"Expose for truth. Shadows are sentences, highlights the punctuation."
},
{
number: "14",
title: "Contemporary Is a Moving Target",
description:
"Design for now by listening deeper than now. The signal is older than the feed."
},
{
number: "15",
title: "Vision, Then Precision",
description:
"Dream wide, ship tight. Let imagination roam and execution walk in single-point focus."
},
{
number: "16",
title: "Geometry of Poise",
description:
"Angles carry attitude. Align posture, light, and line until the frame breathes."
},
{
number: "17",
title: "Natural Light, Natural Truth",
description:
"Open the window and remove the mask. Authenticity needs less wattage, more honesty."
},
{
number: "18",
title: "Studio: The Controlled Wild",
description:
"Dial every knob, then listen for the unscripted moment. Keep the lens ready."
},
{
number: "19",
title: "Invent the Angle",
description:
"Rotate the problem ninety degrees. Fresh perspective isn’t luck—it’s a habit."
},
{
number: "20",
title: "Editorial Nerve",
description:
"Carry yourself like you belong, then earn it with craft. The camera can tell."
},
{
number: "21",
title: "Profession Is Practice",
description:
"Repeat the fundamentals until they disappear. Mastery is subtle on purpose."
},
{
number: "22",
title: "Final Frame, Open Door",
description:
"Endings are launchpads. Archive the take, thank the light, and start again at one."
}
];
}
// Custom line splitting function (since we can't use SplitText)
splitTextIntoLines(element, text) {
element.innerHTML = "";
// Split by sentences and create lines
const sentences = text.split(/(?<=[.!?])\s+/);
const lines = [];
// Create temporary div to measure text width
const temp = document.createElement("div");
temp.style.cssText = `
position: absolute;
visibility: hidden;
width: ${element.offsetWidth}px;
font-family: 'PPNeueMontreal', sans-serif;
font-size: 16px;
font-weight: 300;
line-height: 1.4;
`;
document.body.appendChild(temp);
let currentLine = "";
sentences.forEach((sentence) => {
const words = sentence.split(" ");
words.forEach((word) => {
const testLine = currentLine ? `${currentLine} ${word}` : word;
temp.textContent = testLine;
if (temp.offsetWidth > element.offsetWidth && currentLine) {
lines.push(currentLine);
currentLine = word;
} else {
currentLine = testLine;
}
});
});
if (currentLine) {
lines.push(currentLine);
}
document.body.removeChild(temp);
// Create line elements
lines.forEach((lineText) => {
const lineSpan = document.createElement("span");
lineSpan.className = "description-line";
lineSpan.textContent = lineText;
element.appendChild(lineSpan);
});
return element.querySelectorAll(".description-line");
}
calculateGapForZoom(zoomLevel) {
if (zoomLevel >= 1.0) return 16;
else if (zoomLevel >= 0.6) return 32;
else return 64;
}
calculateGridDimensions(gap = this.config.currentGap) {
const totalWidth = this.config.cols * (this.config.itemSize + gap) - gap;
const totalHeight = this.config.rows * (this.config.itemSize + gap) - gap;
this.gridDimensions = {
width: totalWidth,
height: totalHeight,
scaledWidth: totalWidth * this.config.currentZoom,
scaledHeight: totalHeight * this.config.currentZoom,
gap: gap
};
return this.gridDimensions;
}
generateGridItems() {
this.config.currentGap = this.calculateGapForZoom(this.config.currentZoom);
this.calculateGridDimensions();
this.canvasWrapper.style.width = this.gridDimensions.width + "px";
this.canvasWrapper.style.height = this.gridDimensions.height + "px";
this.gridContainer.innerHTML = "";
this.gridItems = [];
let imageIndex = 0;
for (let row = 0; row < this.config.rows; row++) {
for (let col = 0; col < this.config.cols; col++) {
const item = document.createElement("div");
item.className = "grid-item";
// Calculate final grid position
const x = col * (this.config.itemSize + this.config.currentGap);
const y = row * (this.config.itemSize + this.config.currentGap);
// Set to grid position
item.style.left = `${x}px`;
item.style.top = `${y}px`;
// Hide initially - will be positioned and shown in playIntroAnimation
item.style.opacity = "0";
const imageUrl = this.fashionImages[
imageIndex % this.fashionImages.length
];
imageIndex++;
const img = document.createElement("img");
img.src = imageUrl;
img.alt = `Fashion Portrait ${imageIndex}`;
item.appendChild(img);
const itemData = {
element: item,
img: img,
row: row,
col: col,
baseX: x,
baseY: y,
imageUrl: imageUrl,
index: this.gridItems.length
};
// Add click event for zoom
item.addEventListener("click", () => {
if (!this.zoomState.isActive) {
this.soundSystem.play("click");
this.enterZoomMode(itemData);
}
});
this.gridContainer.appendChild(item);
this.gridItems.push(itemData);
}
}
}
setupViewportObserver() {
if (this.viewportObserver) {
this.viewportObserver.disconnect();
}
this.viewportObserver = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
// Skip if this is the currently selected item in zoom mode
if (
this.zoomState.selectedItem &&
entry.target === this.zoomState.selectedItem.element
) {
return;
}
if (entry.isIntersecting) {
entry.target.classList.remove("out-of-view");
gsap.to(entry.target, {
opacity: 1,
duration: 0.6,
ease: "power2.out"
});
} else {
entry.target.classList.add("out-of-view");
gsap.to(entry.target, {
opacity: 0.1,
duration: 0.6,
ease: "power2.out"
});
}
});
},
{
root: null,
threshold: 0.15,
rootMargin: "10%"
}
);
// Observe all grid items
this.gridItems.forEach((item) => {
this.viewportObserver.observe(item.element);
});
}
updateTitleOverlay(imageIndex) {
const data = this.imageData[imageIndex % this.imageData.length];
const numberElement = document.querySelector("#imageSlideNumber span");
const titleElement = document.querySelector("#imageSlideTitle h1");
const descriptionElement = document.getElementById("imageSlideDescription");
if (numberElement && titleElement && descriptionElement) {
numberElement.textContent = data.number;
titleElement.textContent = data.title;
// Split description into lines
this.descriptionLines = this.splitTextIntoLines(
descriptionElement,
data.description
);
}
}
createScalingOverlay(sourceImg) {
const overlay = document.createElement("div");
overlay.className = "scaling-image-overlay";
const img = document.createElement("img");
img.src = sourceImg.src;
img.alt = sourceImg.alt;
overlay.appendChild(img);
document.body.appendChild(overlay);
const sourceRect = sourceImg.getBoundingClientRect();
gsap.set(overlay, {
left: sourceRect.left,
top: sourceRect.top,
width: sourceRect.width,
height: sourceRect.height,
opacity: 1
});
return overlay;
}
enterZoomMode(selectedItemData) {
if (this.zoomState.isActive) return;
this.zoomState.isActive = true;
this.zoomState.selectedItem = selectedItemData;
this.soundSystem.play("open");
// Disable dragging
if (this.draggable) this.draggable.disable();
document.body.classList.add("zoom-mode");
const splitContainer = this.splitScreenContainer;
const zoomTarget = document.getElementById("zoomTarget");
splitContainer.classList.add("active");
gsap.to(splitContainer, {
opacity: 1,
duration: 1.2,
ease: this.customEase
});
this.zoomState.scalingOverlay = this.createScalingOverlay(
selectedItemData.img
);
gsap.set(selectedItemData.img, {
opacity: 0
});
this.zoomState.flipAnimation = Flip.fit(
this.zoomState.scalingOverlay,
zoomTarget,
{
duration: 1.2,
ease: this.customEase,
absolute: true,
onComplete: () => {
this.updateTitleOverlay(selectedItemData.index);
const imageTitleOverlay = this.imageTitleOverlay;
// Reset positions for animation
gsap.set("#imageSlideNumber span", {
y: 20,
opacity: 0
});
gsap.set("#imageSlideTitle h1", {
y: 60,
opacity: 0
});
gsap.set(this.descriptionLines, {
y: 80,
opacity: 0
});
// Show overlay container immediately
imageTitleOverlay.classList.add("active");
gsap.to(imageTitleOverlay, {
opacity: 1,
duration: 0.3,
ease: "power2.out"
});
// Animate in number - much sooner
gsap.to("#imageSlideNumber span", {
duration: 0.8,
y: 0,
opacity: 1,
ease: this.customEase,
delay: 0.1
});
// Animate in title - sooner
gsap.to("#imageSlideTitle h1", {
duration: 0.8,
y: 0,
opacity: 1,
ease: this.customEase,
delay: 0.15
});
// Animate description lines one by one - much sooner
gsap.to(this.descriptionLines, {
duration: 0.8,
y: 0,
opacity: 1,
ease: this.customEase,
delay: 0.2,
stagger: 0.15
});
}
}
);
this.controlsContainer.classList.add("split-mode");
gsap.fromTo(
this.closeButton,
{
x: 40,
opacity: 0
},
{
x: 0,
opacity: 1,
duration: 0.6,
ease: "power2.out",
delay: 0.9
}
);
this.closeButton.classList.add("active");
// Add event listeners
document
.getElementById("splitLeft")
.addEventListener("click", this.handleSplitAreaClick.bind(this));
document
.getElementById("splitRight")
.addEventListener("click", this.handleSplitAreaClick.bind(this));
document.addEventListener("keydown", this.handleZoomKeys.bind(this));
}
handleSplitAreaClick(e) {
if (e.target === e.currentTarget) {
this.exitZoomMode();
}
}
exitZoomMode() {
if (
!this.zoomState.isActive ||
!this.zoomState.selectedItem ||
!this.zoomState.scalingOverlay
)
return;
this.soundSystem.play("close");
document.removeEventListener("keydown", this.handleZoomKeys);
const splitLeft = document.getElementById("splitLeft");
const splitRight = document.getElementById("splitRight");
if (splitLeft)
splitLeft.removeEventListener("click", this.handleSplitAreaClick);
if (splitRight)
splitRight.removeEventListener("click", this.handleSplitAreaClick);
const splitContainer = this.splitScreenContainer;
const selectedElement = this.zoomState.selectedItem.element;
const selectedImg = this.zoomState.selectedItem.img;
if (this.zoomState.flipAnimation) {
this.zoomState.flipAnimation.kill();
}
// Hide title overlay quickly
const overlayElement = this.imageTitleOverlay;
gsap.to(overlayElement, {
opacity: 0,
duration: 0.3,
ease: "power2.out"
});
gsap.to("#imageSlideNumber span", {
duration: 0.4,
y: -20,
opacity: 0,
ease: "power2.out"
});
gsap.to("#imageSlideTitle h1", {
duration: 0.4,
y: -60,
opacity: 0,
ease: "power2.out"
});
if (this.descriptionLines) {
gsap.to(this.descriptionLines, {
duration: 0.4,
y: -80,
opacity: 0,
ease: "power2.out",
stagger: -0.05,
onComplete: () => {
overlayElement.classList.remove("active");
// Reset all text elements
gsap.set("#imageSlideNumber span", {
y: 20,
opacity: 0
});
gsap.set("#imageSlideTitle h1", {
y: 60,
opacity: 0
});
gsap.set(this.descriptionLines, {
y: 80,
opacity: 0
});
}
});
}
gsap.to(this.closeButton, {
duration: 0.3,
opacity: 0,
x: 40,
ease: "power2.in"
});
splitContainer.classList.remove("active");
this.controlsContainer.classList.remove("split-mode");
gsap.to(splitContainer, {
opacity: 0,
duration: 0.8,
ease: "power2.out"
});
Flip.fit(this.zoomState.scalingOverlay, selectedElement, {
duration: 1.2,
ease: this.customEase,
absolute: true,
onComplete: () => {
gsap.set(selectedImg, {
opacity: 1
});
if (this.zoomState.scalingOverlay) {
document.body.removeChild(this.zoomState.scalingOverlay);
this.zoomState.scalingOverlay = null;
}
splitContainer.classList.remove("active");
document.body.classList.remove("zoom-mode");
this.closeButton.classList.remove("active");
if (this.draggable) this.draggable.enable();
this.zoomState.isActive = false;
this.zoomState.selectedItem = null;
this.zoomState.flipAnimation = null;
}
});
if (this.zoomState.scalingOverlay) {
gsap.to(this.zoomState.scalingOverlay, {
opacity: 0.4,
duration: 0.8,
ease: "power2.out"
});
}
}
handleZoomKeys(e) {
if (!this.zoomState.isActive) return;
if (e.key === "Escape") {
this.exitZoomMode();
}
}
calculateBounds() {
const vw = window.innerWidth;
const vh = window.innerHeight;
const { scaledWidth, scaledHeight } = this.gridDimensions;
const marginX = this.config.currentGap * this.config.currentZoom;
const marginY = this.config.currentGap * this.config.currentZoom;
let minX, maxX, minY, maxY;
if (scaledWidth <= vw) {
const centerX = (vw - scaledWidth) / 2;
minX = maxX = centerX;
} else {
maxX = marginX;
minX = vw - scaledWidth - marginX;
}
if (scaledHeight <= vh) {
const centerY = (vh - scaledHeight) / 2;
minY = maxY = centerY;
} else {
maxY = marginY;
minY = vh - scaledHeight - marginY;
}
return {
minX,
maxX,
minY,
maxY
};
}
initDraggable() {
if (this.draggable) {
this.draggable.kill();
}
this.calculateGridDimensions(this.config.currentGap);
const bounds = this.calculateBounds();
this.draggable = Draggable.create(this.canvasWrapper, {
type: "x,y",
bounds: bounds,
edgeResistance: 0.8,
inertia: true,
throwProps: {
x: {
velocity: "auto",
resistance: 300,
end: (endValue) => Math.round(endValue)
},
y: {
velocity: "auto",
resistance: 300,
end: (endValue) => Math.round(endValue)
}
},
onDragStart: () => {
document.body.classList.add("dragging");
this.soundSystem.play("drag-start");
this.lastValidPosition.x = this.draggable.x;
this.lastValidPosition.y = this.draggable.y;
},
onDrag: () => {
this.lastValidPosition.x = this.draggable.x;
this.lastValidPosition.y = this.draggable.y;
},
onDragEnd: () => {
document.body.classList.remove("dragging");
this.soundSystem.play("drag-end");
}
})[0];
}
handleMouseLeave() {
if (document.body.classList.contains("dragging")) {
document.body.classList.remove("dragging");
gsap.to(this.canvasWrapper, {
duration: 0.6,
x: this.lastValidPosition.x,
y: this.lastValidPosition.y,
ease: "power2.out"
});
if (this.draggable) {
this.draggable.endDrag();
}
}
}
calculateFitZoom() {
const vw = window.innerWidth;
const vh = window.innerHeight - 80;
const currentGap = this.calculateGapForZoom(1.0);
const gridWidth =
this.config.cols * (this.config.itemSize + currentGap) - currentGap;
const gridHeight =
this.config.rows * (this.config.itemSize + currentGap) - currentGap;
const margin = 40;
const availableWidth = vw - margin * 2;
const availableHeight = vh - margin * 2;
const zoomToFitWidth = availableWidth / gridWidth;
const zoomToFitHeight = availableHeight / gridHeight;
const fitZoom = Math.min(zoomToFitWidth, zoomToFitHeight);
return Math.max(0.1, Math.min(2.0, fitZoom));
}
playIntroAnimation() {
const vw = window.innerWidth;
const vh = window.innerHeight;
const screenCenterX = vw / 2;
const screenCenterY = vh / 2;
const canvasStyle = getComputedStyle(this.canvasWrapper);
const canvasMatrix = new DOMMatrix(canvasStyle.transform);
const canvasX = canvasMatrix.m41;
const canvasY = canvasMatrix.m42;
const canvasScale = canvasMatrix.a;
const centerX =
(screenCenterX - canvasX) / canvasScale - this.config.itemSize / 2;
const centerY =
(screenCenterY - canvasY) / canvasScale - this.config.itemSize / 2;
// Position items at center but keep hidden
this.gridItems.forEach((itemData, index) => {
const zIndex = this.gridItems.length - index;
gsap.set(itemData.element, {
left: centerX,
top: centerY,
scale: 0.8,
zIndex: zIndex,
opacity: 0 // Keep hidden, will fade in during animation
});
});
// Animate from center to grid positions with fade in
gsap.to(
this.gridItems.map((item) => item.element),
{
duration: 0.2,
left: (index) => this.gridItems[index].baseX,
top: (index) => this.gridItems[index].baseY,
scale: 1,
opacity: 1, // Add fade in
ease: "power2.out",
stagger: {
amount: 1.5,
from: "start",
grid: [this.config.rows, this.config.cols]
},
onComplete: () => {
this.gridItems.forEach((itemData) => {
gsap.set(itemData.element, {
zIndex: 1
});
});
// Show controls with staggered animation
const percentageIndicator = this.controlsContainer.querySelector(
".percentage-indicator"
);
const switchElement = this.controlsContainer.querySelector(".switch");
const soundToggle = this.controlsContainer.querySelector(
".sound-toggle"
);
gsap.set(this.controlsContainer, {
opacity: 0
});
gsap.set(percentageIndicator, {
x: "-3em"
});
gsap.set(switchElement, {
y: "2em"
});
gsap.set(soundToggle, {
x: "3em"
});
const navTimeline = gsap.timeline();
navTimeline.to(
this.controlsContainer,
{
opacity: 1,
duration: 0.5,
ease: "power2.out"
},
0
);
navTimeline.to(
percentageIndicator,
{
x: 0,
duration: 0.2,
ease: "power2.out"
},
0.25
);
navTimeline.to(
switchElement,
{
y: 0,
duration: 0.2,
ease: "power2.out"
},
0.3
);
navTimeline.to(
soundToggle,
{
x: 0,
duration: 0.2,
ease: "power2.out"
},
0.35
);
this.controlsContainer.classList.add("visible");
}
}
);
}
autoFitZoom(buttonElement = null) {
if (this.zoomState.isActive) {
this.exitZoomMode();
return;
}
const fitZoom = this.calculateFitZoom();
this.config.currentZoom = fitZoom;
const newGap = this.calculateGapForZoom(fitZoom);
this.soundSystem.play(fitZoom < 0.6 ? "zoom-out" : "zoom-in");
this.calculateGridDimensions(this.config.currentGap);
const vw = window.innerWidth;
const vh = window.innerHeight;
const currentScaledWidth =
this.gridDimensions.width * this.config.currentZoom;
const currentScaledHeight =
this.gridDimensions.height * this.config.currentZoom;
const centerX = (vw - currentScaledWidth) / 2;
const centerY = (vh - currentScaledHeight) / 2;
gsap.to(this.canvasWrapper, {
duration: 0.6,
x: centerX,
y: centerY,
ease: this.centerEase,
onComplete: () => {
if (newGap !== this.config.currentGap) {
this.gridItems.forEach((itemData) => {
const newX = itemData.col * (this.config.itemSize + newGap);
const newY = itemData.row * (this.config.itemSize + newGap);
itemData.baseX = newX;
itemData.baseY = newY;
gsap.to(itemData.element, {
duration: 1.0,
left: newX,
top: newY,
ease: this.customEase
});
});
const newWidth =
this.config.cols * (this.config.itemSize + newGap) - newGap;
const newHeight =
this.config.rows * (this.config.itemSize + newGap) - newGap;
gsap.to(this.canvasWrapper, {
duration: 1.0,
width: newWidth,
height: newHeight,
ease: this.customEase
});
this.config.currentGap = newGap;
}
this.calculateGridDimensions(newGap);
const finalScaledWidth = this.gridDimensions.width * fitZoom;
const finalScaledHeight = this.gridDimensions.height * fitZoom;
const finalCenterX = (vw - finalScaledWidth) / 2;
const finalCenterY = (vh - finalScaledHeight) / 2;
gsap.to(this.canvasWrapper, {
duration: 1.2,
scale: fitZoom,
x: finalCenterX,
y: finalCenterY,
ease: this.customEase,
onComplete: () => {
this.lastValidPosition.x = finalCenterX;
this.lastValidPosition.y = finalCenterY;
this.initDraggable();
}
});
}
});
this.updatePercentageIndicator(fitZoom);
document.querySelectorAll(".switch-button").forEach((btn) => {
btn.classList.remove("switch-button-current");
});
if (buttonElement) {
buttonElement.classList.add("switch-button-current");
}
}
updatePercentageIndicator(zoomLevel) {
const percentage = Math.round(zoomLevel * 100);
document.getElementById(
"percentageIndicator"
).textContent = `${percentage}%`;
}
setZoom(zoomLevel, buttonElement = null) {
if (this.zoomState.isActive) {
this.exitZoomMode();
return;
}
const newGap = this.calculateGapForZoom(zoomLevel);
const oldZoom = this.config.currentZoom;
this.config.currentZoom = zoomLevel;
this.soundSystem.play(zoomLevel < oldZoom ? "zoom-out" : "zoom-in");
this.calculateGridDimensions(this.config.currentGap);
const vw = window.innerWidth;
const vh = window.innerHeight;
const currentScaledWidth = this.gridDimensions.width * oldZoom;
const currentScaledHeight = this.gridDimensions.height * oldZoom;
const centerX = (vw - currentScaledWidth) / 2;
const centerY = (vh - currentScaledHeight) / 2;
gsap.to(this.canvasWrapper, {
duration: 0.6,
x: centerX,
y: centerY,
ease: this.centerEase,
onComplete: () => {
if (newGap !== this.config.currentGap) {
this.gridItems.forEach((itemData) => {
const newX = itemData.col * (this.config.itemSize + newGap);
const newY = itemData.row * (this.config.itemSize + newGap);
itemData.baseX = newX;
itemData.baseY = newY;
gsap.to(itemData.element, {
duration: 1.2,
left: newX,
top: newY,
ease: this.customEase
});
});
const newWidth =
this.config.cols * (this.config.itemSize + newGap) - newGap;
const newHeight =
this.config.rows * (this.config.itemSize + newGap) - newGap;
gsap.to(this.canvasWrapper, {
duration: 1.2,
width: newWidth,
height: newHeight,
ease: this.customEase
});
this.config.currentGap = newGap;
}
this.calculateGridDimensions(newGap);
const finalScaledWidth = this.gridDimensions.width * zoomLevel;
const finalScaledHeight = this.gridDimensions.height * zoomLevel;
const finalCenterX = (vw - finalScaledWidth) / 2;
const finalCenterY = (vh - finalScaledHeight) / 2;
gsap.to(this.canvasWrapper, {
duration: 1.2,
scale: zoomLevel,
x: finalCenterX,
y: finalCenterY,
ease: this.customEase,
onComplete: () => {
this.lastValidPosition.x = finalCenterX;
this.lastValidPosition.y = finalCenterY;
this.calculateGridDimensions(newGap);
this.initDraggable();
}
});
}
});
this.updatePercentageIndicator(zoomLevel);
document.querySelectorAll(".switch-button").forEach((btn) => {
btn.classList.remove("switch-button-current");
});
if (buttonElement) {
buttonElement.classList.add("switch-button-current");
} else {
const buttons = document.querySelectorAll(".switch-button");
if (zoomLevel === 0.3) buttons[1].classList.add("switch-button-current");
else if (zoomLevel === 0.6)
buttons[2].classList.add("switch-button-current");
else if (zoomLevel === 1.0)
buttons[3].classList.add("switch-button-current");
}
}
resetPosition() {
if (this.zoomState.isActive) {
this.exitZoomMode();
return;
}
this.calculateGridDimensions(this.config.currentGap);
const vw = window.innerWidth;
const vh = window.innerHeight;
const { scaledWidth, scaledHeight } = this.gridDimensions;
const centerX = (vw - scaledWidth) / 2;
const centerY = (vh - scaledHeight) / 2;
gsap.to(this.canvasWrapper, {
duration: 1.0,
x: centerX,
y: centerY,
ease: this.centerEase,
onComplete: () => {
this.lastValidPosition.x = centerX;
this.lastValidPosition.y = centerY;
this.initDraggable();
}
});
}
init() {
this.config.currentGap = this.calculateGapForZoom(this.config.currentZoom);
this.generateGridItems();
// Set initial opacity for viewport to hide the flash
gsap.set(this.viewport, { opacity: 0 });
gsap.set(this.canvasWrapper, {
scale: this.config.currentZoom
});
this.calculateGridDimensions(this.config.currentGap);
const vw = window.innerWidth;
const vh = window.innerHeight;
const { scaledWidth, scaledHeight } = this.gridDimensions;
const centerX = (vw - scaledWidth) / 2;
const centerY = (vh - scaledHeight) / 2;
gsap.set(this.canvasWrapper, {
x: centerX,
y: centerY
});
this.lastValidPosition.x = centerX;
this.lastValidPosition.y = centerY;
this.updatePercentageIndicator(this.config.currentZoom);
// Setup event listeners
this.setupEventListeners();
// Fade in viewport, then play animations
gsap.to(this.viewport, {
duration: 0.6,
opacity: 1,
ease: "power2.inOut",
onComplete: () => {
this.playIntroAnimation();
gsap.to(".header", {
duration: 1.2,
opacity: 1,
ease: "power2.out",
delay: 0.8
});
gsap.to(".footer", {
duration: 1.4,
opacity: 1,
ease: "power2.out",
delay: 1
});
setTimeout(() => {
this.initDraggable();
this.setupViewportObserver();
}, 1500);
}
});
}
setupEventListeners() {
window.addEventListener("resize", () => {
setTimeout(() => {
this.resetPosition();
this.initDraggable();
}, 100);
});
document.addEventListener("mouseleave", () => this.handleMouseLeave());
this.viewport.addEventListener("mouseleave", () => this.handleMouseLeave());
this.closeButton.addEventListener("click", () => this.exitZoomMode());
this.soundToggle.addEventListener("click", () => this.soundSystem.toggle());
// Keyboard shortcuts
document.addEventListener("keydown", (e) => {
if (this.zoomState.isActive) return;
switch (e.key) {
case "1":
this.setZoom(0.3);
break;
case "2":
this.setZoom(0.6);
break;
case "3":
this.setZoom(1.0);
break;
case "f":
case "F":
this.autoFitZoom();
break;
}
});
}
}
// Initialize gallery with preloader
let gallery;
document.addEventListener("DOMContentLoaded", () => {
const preloader = new PreloaderManager();
// Wait for preloader to complete, then initialize gallery
setTimeout(() => {
preloader.complete(() => {
// Initialize gallery after preloader fades out
gallery = new FashionGallery();
gallery.init();
});
}, 2000); // 2 seconds preloader duration
});
<script src="https://cdn.jsdelivr.net/npm/gsap@3.13.0/dist/gsap.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.13.0/dist/Draggable.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.13.0/dist/InertiaPlugin.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.13.0/dist/CustomEase.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.13.0/dist/Flip.min.js"></script>
@import url("https://fonts.cdnfonts.com/css/thegoodmonolith");
@font-face {
font-family: "PPNeueMontreal";
src: url("https://assets.codepen.io/7558/PPNeueMontreal-Variable.woff2")
format("woff2");
font-weight: 100 900;
font-style: normal;
}
:root {
--spacing-base: 1rem;
--spacing-md: 1.5rem;
--spacing-lg: 2rem;
--color-text: #ffffff;
--color-text-dim: 0.6;
--transition-medium: 0.3s ease;
--font-size-base: 14px;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
user-select: none;
}
body {
font-family: "PPNeueMontreal", sans-serif;
background: #000;
overflow: hidden;
height: 100vh;
cursor: grab;
}
body.dragging {
cursor: grabbing;
}
body.zoom-mode {
cursor: default;
}
.preloader-overlay {
background: #000;
}
/* Header and Footer */
.header,
.footer {
position: fixed;
left: 0;
width: 100vw;
padding: 1.5rem;
z-index: 10000;
display: grid;
grid-template-columns: repeat(12, 1fr);
column-gap: var(--spacing-base);
pointer-events: none;
opacity: 0;
}
.header > *,
.footer > * {
pointer-events: auto;
}
.header {
top: 0;
}
.footer {
bottom: 0;
}
/* Grid column assignments */
.nav-section {
grid-column: 1 / span 3;
}
.values-section {
grid-column: 5 / span 2;
}
.location-section {
grid-column: 7 / span 2;
}
.contact-section {
grid-column: 9 / span 2;
}
.social-section {
grid-column: 11 / span 2;
text-align: right;
}
/* Bottom bar */
.coordinates-section {
grid-column: 1 / span 3;
font-family: "TheGoodMonolith", monospace;
}
.info-section {
grid-column: 9 / span 4;
text-align: right;
}
/* ===== LOGO COMPONENT ===== */
.logo-container {
margin-bottom: var(--spacing-md);
display: block;
width: 3rem;
height: 1.5rem;
position: relative;
cursor: pointer;
}
.logo-circles {
position: relative;
width: 100%;
height: 100%;
}
.circle {
position: absolute;
border-radius: 50%;
transition: transform var(--transition-medium);
width: 1.4rem;
height: 1.4rem;
background-color: var(--color-text);
top: 50%;
}
.circle-1 {
left: 0;
transform: translate(0, -50%);
}
.circle-2 {
left: 0.8rem;
transform: translate(0, -50%);
mix-blend-mode: exclusion;
}
.logo-container:hover .circle-1 {
transform: translate(-0.5rem, -50%);
}
.logo-container:hover .circle-2 {
transform: translate(0.5rem, -50%);
}
/* Key hint styling */
.key-hint {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0 5px;
border: 1px solid var(--color-text);
border-radius: 3px;
font-size: 12px;
margin: 0 3px;
min-width: 20px;
height: 20px;
}
/* Footer text styling */
.footer p {
font-family: "TheGoodMonolith", monospace;
}
/* Global link styling */
a {
position: relative;
cursor: pointer;
color: var(--color-text);
padding: 0;
display: inline-block;
z-index: 1;
text-decoration: none;
font-size: var(--font-size-base);
opacity: 1;
transition: color var(--transition-medium);
font-weight: 700;
}
a::after {
content: "";
position: absolute;
top: 0;
left: 0;
width: 0;
height: 100%;
background-color: var(--color-text);
z-index: -1;
transition: width 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
}
a:hover::after {
width: 100%;
}
a:hover {
color: black;
mix-blend-mode: difference;
opacity: 1;
}
p {
display: block;
text-decoration: none;
color: #ffffff;
font-size: 14px;
font-weight: 600;
letter-spacing: -0.01rem;
-webkit-font-smoothing: antialiased;
}
ul {
list-style: none;
}
h3 {
font-size: 14px;
margin-bottom: var(--spacing-base);
font-weight: 600;
color: #fff;
}
.viewport {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
overflow: hidden;
z-index: 1;
opacity: 0;
}
.canvas-wrapper {
position: absolute;
top: 0;
left: 0;
transform-origin: 0 0;
will-change: transform;
isolation: isolate;
}
.grid-container {
position: relative;
width: 100%;
height: 100%;
}
.grid-item {
position: absolute;
width: 320px;
height: 320px;
background: #000;
cursor: pointer;
will-change: transform, opacity;
z-index: 1;
opacity: 1;
transition: opacity 0.6s ease;
}
.grid-item.out-of-view {
opacity: 0.1;
}
.grid-item.selected {
z-index: 2 !important;
}
.grid-item img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
user-select: none;
pointer-events: none;
}
/* Split Screen Layout */
.split-screen-container {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
display: flex;
z-index: 2;
opacity: 0;
pointer-events: none;
}
.split-screen-container.active {
opacity: 1;
pointer-events: all;
}
.split-left {
position: relative;
width: 50vw;
height: 100vh;
background: rgba(0, 0, 0, 0.6);
display: flex;
justify-content: center;
align-items: center;
z-index: 1;
cursor: pointer;
}
.split-right {
position: relative;
width: 50vw;
height: 100vh;
background: rgba(0, 0, 0, 0.6);
display: flex;
justify-content: center;
align-items: center;
z-index: 1;
cursor: pointer;
}
/* Image target - BEHIND the scaling image */
.zoom-target {
width: 100%;
height: 100%;
position: relative;
display: flex;
align-items: center;
justify-content: center;
z-index: 1;
}
/* Image title overlay - positioned at bottom left */
.image-title-overlay {
position: absolute;
bottom: 40px;
left: 40px;
transform: none;
color: white;
z-index: 4;
opacity: 0;
pointer-events: none;
}
.image-title-overlay.active {
opacity: 0;
}
.image-slide-number {
position: relative;
width: 400px;
height: 20px;
margin-bottom: 0.5em;
clip-path: polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%);
overflow: hidden;
}
.image-slide-number span {
position: absolute;
top: 0;
left: 0;
color: white;
font-family: "TheGoodMonolith", monospace;
font-size: 12px;
font-weight: 400;
line-height: 1.5;
transform: translateY(0px);
will-change: transform;
margin: 0;
padding: 0;
text-transform: uppercase;
letter-spacing: 0.1em;
}
.image-slide-title {
position: relative;
width: 400px;
height: 60px;
margin-bottom: 1em;
clip-path: polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%);
overflow: hidden;
}
.image-slide-title h1 {
position: absolute;
top: 0;
left: 0;
color: white;
font-family: "PPNeueMontreal", sans-serif;
font-size: 48px;
font-weight: 500;
letter-spacing: -0.02em;
line-height: 1.2;
transform: translateY(0px);
will-change: transform;
margin: 0;
padding: 0;
}
.image-slide-description {
position: relative;
width: 400px;
min-height: 80px;
clip-path: polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%);
overflow: hidden;
}
.description-line {
position: relative;
display: block;
color: rgba(255, 255, 255, 0.8);
font-family: "PPNeueMontreal", sans-serif;
font-size: 16px;
font-weight: 300;
line-height: 1.4;
transform: translateY(0px);
will-change: transform;
margin: 0;
padding: 0;
overflow: hidden;
}
@media (max-width: 900px) {
.image-title-overlay {
bottom: 20px;
left: 20px;
}
.image-slide-number {
width: 300px;
height: 18px;
}
.image-slide-number span {
font-size: 10px;
}
.image-slide-title {
width: 300px;
height: 50px;
}
.image-slide-title h1 {
font-size: 36px;
}
.image-slide-description {
width: 300px;
min-height: 70px;
}
.description-line {
font-size: 14px;
}
}
/* Hide placeholder when active */
.split-screen-container.active .zoom-target::before {
display: none;
}
.zoom-target::before {
content: "IMAGE TARGET";
color: rgba(255, 255, 255, 0.5);
font-family: "TheGoodMonolith", monospace;
font-size: 0.75em;
font-weight: 400;
text-transform: uppercase;
letter-spacing: 2px;
}
.controls-container {
position: fixed;
bottom: 1.25em;
left: 50%;
transform: translateX(-50%);
display: flex;
z-index: 6;
opacity: 0;
transition: left 1.2s cubic-bezier(0.87, 0, 0.13, 1);
}
.controls-container.visible {
opacity: 1;
}
.controls-container.split-mode {
left: 75%;
}
.percentage-indicator {
background-color: #f0f0f0;
background-image: radial-gradient(rgba(0, 0, 0, 0.015) 1px, transparent 0);
background-size: 0.44em 0.44em;
background-position: -0.06em -0.06em;
padding: 0.625em 1.25em;
border-radius: 0.25em;
display: flex;
align-items: center;
justify-content: center;
font-family: "TheGoodMonolith", monospace;
font-size: 0.75em;
font-weight: 400;
text-transform: uppercase;
color: #333;
min-width: 5em;
white-space: nowrap;
}
.switch {
display: flex;
gap: 1.25em;
background-color: #222;
background-image: radial-gradient(
rgba(255, 255, 255, 0.015) 1px,
transparent 0
);
background-size: 0.44em 0.44em;
background-position: -0.06em -0.06em;
padding: 0.625em 1.25em;
border-radius: 0.25em;
transition: padding 0.3s ease-in-out;
}
.sound-toggle {
background-color: #f0f0f0;
background-image: radial-gradient(rgba(0, 0, 0, 0.015) 1px, transparent 0);
background-size: 0.44em 0.44em;
background-position: -0.06em -0.06em;
padding: 0.5em 0.75em;
border-radius: 0.25em;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.3s ease;
min-width: 3.75em;
position: relative;
border-color: transparent;
}
.sound-wave-canvas {
width: 2em;
height: 1em;
border: none !important;
outline: none !important;
background: none !important;
}
.sound-toggle.active .sound-wave-canvas {
opacity: 1;
}
.sound-toggle:hover .sound-wave-canvas {
opacity: 0.8;
}
.switch-button {
background: none;
border: none;
border-color: transparent;
color: #666;
cursor: pointer;
font-family: "TheGoodMonolith", monospace;
font-size: 0.75em;
font-weight: 400;
text-transform: uppercase;
padding: 5px 10px;
position: relative;
transition: all 0.3s ease-in-out;
white-space: nowrap;
}
.switch-button-current {
color: #f0f0f0;
}
.indicator-dot {
position: absolute;
width: 5px;
height: 5px;
background-color: #f0f0f0;
border-radius: 50%;
opacity: 0;
transition: opacity 0.3s ease-in-out;
top: 50%;
transform: translateY(-50%);
left: -8px;
}
.switch-button:hover .indicator-dot {
opacity: 1;
}
/* Simple 64px white arrow button */
.close-button {
position: fixed;
top: 50%;
right: 20px;
width: 64px;
height: 64px;
background: none;
border: none;
border-color: transparent;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
z-index: 5;
opacity: 0;
pointer-events: none;
transform: translate(40px, -50%);
}
.close-button.active {
pointer-events: all;
}
.close-button:hover {
opacity: 0.7;
}
.close-button svg {
width: 64px;
height: 64px;
transform: rotate(180deg);
}
/* Scaling image overlay */
.scaling-image-overlay {
position: fixed;
top: 0;
left: 0;
z-index: 3;
pointer-events: none;
will-change: transform;
opacity: 1 !important;
}
.scaling-image-overlay img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
/* Page vignette effect */
.page-vignette-container {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 9998;
}
.page-vignette-extreme {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
mix-blend-mode: overlay;
background: linear-gradient(
to bottom,
rgba(0, 0, 0, 0.9) 0%,
rgba(0, 0, 0, 0.5) 20%,
transparent 40%
);
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment