<div class="loading" id="app-container">
<!-- Tall invisible container to provide scrollable space -->
<div class="scroll-content" style="height: 600vh;">
</div>
<!-- Background Marquee -->
<div class="mark">
<div class="mark__inner">
<span>
máquina espacial - animations
</span>
<span>
/
</span>
<span>
máquina espacial - animations
</span>
<span>
/
</span>
<span>
máquina espacial - animations
</span>
<span>
/
</span>
<span>
máquina espacial - animations
</span>
<span>
/
</span>
<span>
máquina espacial - animations
</span>
<span>
/
</span>
</div>
</div>
<div id="scene">
<img alt="Vinyl" id="vinyl-record" src="https://storage.maquinaespacial.com/media/disco_vinilo.png"/>
<!-- 16 photos: Group A (0-7), Group B (8-15) -->
<div class="photo-frame">
<img src="https://storage.maquinaespacial.com/templates/saturn/media/ART-1.jpg"/>
</div>
<div class="photo-frame">
<img src="https://storage.maquinaespacial.com/templates/saturn/media/ART-2.jpg"/>
</div>
<div class="photo-frame">
<img src="https://storage.maquinaespacial.com/templates/saturn/media/ART-3.jpg"/>
</div>
<div class="photo-frame">
<img src="https://storage.maquinaespacial.com/templates/saturn/media/ART-4.jpg"/>
</div>
<div class="photo-frame">
<img src="https://storage.maquinaespacial.com/templates/saturn/media/ART-5.jpg"/>
</div>
<div class="photo-frame">
<img src="https://storage.maquinaespacial.com/templates/saturn/media/ART-6.jpg"/>
</div>
<div class="photo-frame">
<img src="https://storage.maquinaespacial.com/templates/saturn/media/ART-7.jpg"/>
</div>
<div class="photo-frame">
<img src="https://storage.maquinaespacial.com/templates/saturn/media/ART-8.jpg"/>
</div>
<div class="photo-frame">
<img src="https://storage.maquinaespacial.com/templates/saturn/media/ART-16.jpg"/>
</div>
<div class="photo-frame">
<img src="https://storage.maquinaespacial.com/templates/saturn/media/ART-17.jpg"/>
</div>
<div class="photo-frame">
<img src="https://storage.maquinaespacial.com/templates/saturn/media/ART-18.jpg"/>
</div>
<div class="photo-frame">
<img src="https://storage.maquinaespacial.com/templates/saturn/media/ART-19.jpg"/>
</div>
<div class="photo-frame">
<img src="https://storage.maquinaespacial.com/templates/saturn/media/ART-20.jpg"/>
</div>
<div class="photo-frame">
<img src="https://storage.maquinaespacial.com/templates/saturn/media/ART-21.jpg"/>
</div>
<div class="photo-frame">
<img src="https://storage.maquinaespacial.com/templates/saturn/media/ART-22.jpg"/>
</div>
<div class="photo-frame">
<img src="https://storage.maquinaespacial.com/templates/saturn/media/ART-23.jpg"/>
</div>
</div>
</div>
/* BIT_CSS_START */
*,
*::after,
*::before {
box-sizing: border-box;
}
:root {
--color-bg: #fff;
--color-text: #111827;
--color-marquee: rgb(0, 115, 255);
/* Inverted Orange for mix-blend-mode */
--vinyl-size: 35vmin;
--photo-w: 22vmin;
--photo-h: 30vmin;
}
@media (max-width: 768px) {
:root {
--vinyl-size: 50vmin;
--photo-w: 35vmin;
--photo-h: 25vmin;
}
}
body {
margin: 0;
color: var(--color-text);
background-color: var(--color-bg);
font-family: 'Inter', sans-serif;
overflow-x: clip;
}
#app-container {
width: 100%;
position: relative;
background-color: var(--color-bg);
}
.scroll-content {
/* Provides scrolling real estate */
width: 100%;
}
#scene {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
height: 100dvh;
overflow: hidden;
pointer-events: none;
perspective: 1200px;
/* Added for 3D flip effect */
}
/* Background shadow gradient */
#app-container::after {
content: "";
top: 0;
left: 0;
position: fixed;
width: 100%;
height: 100%;
z-index: 1;
/* Below the scene but above background */
pointer-events: none;
background: radial-gradient(rgba(0, 0, 0, 0.1) 10%, rgba(255, 140, 0, 0.15) 80%);
}
.mark {
position: fixed;
width: 100vw;
top: 50%;
transform: translateY(-50%);
left: 0;
z-index: 2;
/* Below the vinyl but above background */
overflow: hidden;
mix-blend-mode: difference;
pointer-events: none;
}
.mark__inner {
display: flex;
gap: 3rem;
width: max-content;
position: relative;
will-change: transform;
}
.mark__inner span {
white-space: nowrap;
text-transform: uppercase;
color: var(--color-marquee);
font-size: clamp(1.5rem, 8vw, 6rem);
/* Smaller font size */
line-height: 1;
font-weight: 300;
/* Thinner weight */
font-family: 'Inter', sans-serif;
/* Clean sans-serif for thin look */
}
#vinyl-record {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: var(--vinyl-size);
height: var(--vinyl-size);
object-fit: contain;
z-index: 10;
will-change: transform;
}
.photo-frame {
position: absolute;
top: 50%;
left: 50%;
width: var(--photo-w);
height: var(--photo-h);
border-radius: 12px;
overflow: hidden;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
/* The anchor is the center, so x/y logic maps to center */
margin-top: calc(var(--photo-h) / -2);
margin-left: calc(var(--photo-w) / -2);
will-change: transform;
background-color: #ccc;
transform-style: preserve-3d;
}
.photo-frame img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
/* Loading state */
.loading::before,
.loading::after {
content: "";
position: fixed;
z-index: 10000;
}
.loading::before {
top: 0;
left: 0;
width: 100%;
height: 100%;
background: var(--color-bg);
}
.loading::after {
top: 50%;
left: 50%;
width: 100px;
height: 2px;
margin: 0 0 0 -50px;
background: var(--color-text);
animation: loaderAnim 1.5s ease-in-out infinite alternate forwards;
}
@keyframes loaderAnim {
0% {
transform: scaleX(0);
transform-origin: 0% 50%;
}
50% {
transform: scaleX(1);
transform-origin: 0% 50%;
}
50.1% {
transform: scaleX(1);
transform-origin: 100% 50%;
}
100% {
transform: scaleX(0);
transform-origin: 100% 50%;
}
}
/* BIT_CSS_END */
gsap.registerPlugin(ScrollTrigger);
let lenis;
let scrollY = 0;
const items = gsap.utils.toArray('.photo-frame');
const vinylEl = document.getElementById("vinyl-record");
let layoutParams = {
isMobile: false,
vinylRadius: 0,
safetyRadius: 0,
spacing: 0,
loopSize: 0,
minCoord: 0,
maxCoord: 0,
baseCoord: 0
};
function calculateLayout() {
layoutParams.isMobile = window.innerWidth < 768;
const vRect = vinylEl.getBoundingClientRect();
layoutParams.vinylRadius = vRect.width / 2;
const pRect = items[0].getBoundingClientRect();
const photoW = pRect.width;
const photoH = pRect.height;
// The safety radius determines how far the photos must stay away from the center
layoutParams.safetyRadius = layoutParams.vinylRadius + (layoutParams.isMobile ? photoH / 2 : photoW / 2) + 30;
// By increasing baseCoord to 0.7, the column naturally starts closer to the edge, making the curve much more subtle
layoutParams.baseCoord = layoutParams.safetyRadius * 0.7;
// Calculate infinite loop spacing
layoutParams.spacing = layoutParams.isMobile ? window.innerWidth * 0.45 : window.innerHeight * 0.45;
layoutParams.loopSize = 8 * layoutParams.spacing; // 8 items per column
// Center the loop wrap around the middle of the screen
layoutParams.minCoord = -layoutParams.loopSize / 2;
layoutParams.maxCoord = layoutParams.loopSize / 2;
}
window.addEventListener('resize', calculateLayout);
function initSmoothScrolling() {
lenis = new Lenis({
duration: 1.2,
easing: (t) => Math.min(1, 1.001 - Math.pow(2, -10 * t)),
smoothWheel: true
});
lenis.on('scroll', (e) => {
scrollY = e.scroll;
});
gsap.ticker.add((time) => {
lenis.raf(time * 1000);
renderFrame();
});
gsap.ticker.lagSmoothing(0);
}
function renderFrame() {
if (layoutParams.safetyRadius === 0) return; // Not initialized
// 1. Spin the vinyl dynamically based on scroll position
gsap.set(vinylEl, {
rotation: scrollY * 0.2
});
const {
isMobile,
safetyRadius,
baseCoord,
spacing,
minCoord,
maxCoord
} = layoutParams;
// 2. Position the two columns using the Pythagorean Curve Algorithm
items.forEach((item, i) => {
// We have 16 items. Group A (0-7), Group B (8-15)
const isGroupA = i < 8;
const groupIndex = i % 8;
// Group A moves opposite to Group B on scroll
const direction = isGroupA ? -1 : 1;
// Calculate raw position on the primary axis (Y for desktop, X for mobile)
const rawPos = (groupIndex * spacing) + (scrollY * 0.8 * direction);
// Wrap infinitely using GSAP util (seamless because it wraps far off-screen)
const pos = gsap.utils.wrap(minCoord, maxCoord, rawPos);
let x = 0,
y = 0,
z = 0,
rotation = 0,
rotationX = 0,
rotationY = 0;
if (isMobile) {
// Mobile: Horizontal scrolling. Base Y is top/bottom.
const baseY = isGroupA ? -baseCoord : baseCoord;
x = pos;
y = baseY;
// Collision detection with center (0,0)
if (Math.abs(x) < safetyRadius) {
// Calculate the Y boundary of the circle at this X using Pythagoras (y = sqrt(r^2 - x^2))
const requiredY = Math.sqrt(Math.pow(safetyRadius, 2) - Math.pow(x, 2));
// Push outward
y = isGroupA ? Math.min(baseY, -requiredY) : Math.max(baseY, requiredY);
// Calculate slope to tilt the image smoothly as it rides the curve
rotation = Math.atan(-x / y) * (180 / Math.PI) * 0.4;
// 3D Flip & Depth Push
const p = x / safetyRadius; // -1 to 1 representing progress through curve
rotationX = p * 180 * (isGroupA ? -1 : 1);
z = Math.sin(Math.acos(Math.abs(p))) * -150;
}
} else {
// Desktop: Vertical scrolling. Base X is left/right.
const baseX = isGroupA ? -baseCoord : baseCoord;
x = baseX;
y = pos;
if (Math.abs(y) < safetyRadius) {
const requiredX = Math.sqrt(Math.pow(safetyRadius, 2) - Math.pow(y, 2));
x = isGroupA ? Math.min(baseX, -requiredX) : Math.max(baseX, requiredX);
rotation = Math.atan(-y / x) * (180 / Math.PI) * 0.4;
// 3D Flip & Depth Push
const p = y / safetyRadius; // -1 to 1 representing progress through curve
rotationY = p * 180 * (isGroupA ? -1 : 1);
z = Math.sin(Math.acos(Math.abs(p))) * -150;
}
}
gsap.set(item, {
x,
y,
z,
rotation,
rotationX,
rotationY,
force3D: true
});
});
}
function animateMarquee() {
const marqueeInner = document.querySelector('.mark__inner');
if (!marqueeInner) return;
gsap.fromTo(marqueeInner, {
x: '0%'
}, {
x: '-50%',
ease: 'none',
scrollTrigger: {
trigger: ".scroll-content",
start: 'top top',
end: 'bottom bottom',
scrub: true,
}
});
}
const preloadImages = (selector = '.photo-frame img') => {
return new Promise((resolve) => {
imagesLoaded(document.querySelectorAll(selector), resolve);
});
};
function startApp() {
preloadImages().then(() => {
const app = document.getElementById('app-container');
if (app) app.classList.remove('loading');
calculateLayout();
initSmoothScrolling();
animateMarquee();
// Render first frame immediately to prevent jump
renderFrame();
}).catch(err => {
console.error("Images loaded error:", err);
const app = document.getElementById('app-container');
if (app) app.classList.remove('loading');
calculateLayout();
initSmoothScrolling();
animateMarquee();
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', startApp);
} else {
startApp();
}
Skills & Techniques Used
- High-Performance Math Loop: The core of the bit runs in
gsap.ticker. To ensure 60fps without layout thrashing, all DOM-heavy getBoundingClientRect() calls are cached on window.resize. Inside the loop, only raw math and CSS Transform updates (x, y, z, rotation, rotationX, rotationY) are executed.
- Infinite Wrapping: Leverages
gsap.utils.wrap(min, max, value) to infinitely cycle a small array of 16 DOM elements. When an image moves off-screen, it instantly teleports to the opposite side, giving the illusion of an endless feed.
- Pythagorean Collision Resolution:
- Calculates collision bounds:
Math.abs(axis) < safetyRadius
- Calculates required push-out distance:
Math.sqrt(Math.pow(safetyRadius, 2) - Math.pow(axis, 2))
- 3D Card Flip: As the images enter the collision radius, their position is normalized into a progress variable
p (-1 to 1). This is multiplied by 180 to trigger a smooth 180-degree 3D rotation around the Y-axis (or X-axis on mobile), while simultaneously using Math.sin(Math.acos(Math.abs(p))) * -150 to push the image into the Z-axis depth plane, avoiding overlapping the central vinyl.
Design Decisions
Brutalist / Minimalist Aesthetic
The design intentionally relies on extreme contrast:
- A bright #fff background paired with a deep shadow gradient.
- Absolute minimal padding, relying on massive, thin typography (font-weight: 300) to fill the background space elegantly.
Mix-Blend-Mode Magic
The massive background text ("máquina espacial - animations") uses mix-blend-mode: difference. To achieve a perfect neon-orange transparent overlay on a white background, the text color is actually painted in inverted azure blue (rgb(0, 115, 255)). When the browser calculates the mathematical color difference against the white background and gray images, it yields the striking translucent orange you see on screen.
Mobile Adaptation
Vertical columns on a narrow phone screen leave very little horizontal room. By detecting window width (< 768px) and rotating the math 90 degrees, the images become horizontal carousels that curve vertically around the record, utilizing the phone's height beautifully.
Máquina Espacial - Curved Infinite Scroll
This bit features a bespoke GSAP engine written exclusively for "Máquina Espacial". It replaces standard vertical scrolling with a dual-directional infinite scrolling system where two columns of images move in opposite directions.
At the center lies a spinning vinyl record whose rotation is directly driven by your scroll velocity. To prevent the images from colliding with the record, the animation dynamically calculates a "Pythagorean Curve", pushing the images outward along a circular path in 3D space, flipping them 180 degrees as they pass the center.
Libraries Used
- GSAP (GreenSock Animation Platform): The core engine. We use
gsap.set and gsap.ticker to manually override the DOM at 60fps instead of relying on standard CSS transitions.
- ScrollTrigger (GSAP Plugin): Used exclusively to drive the infinite horizontal background marquee ("máquina espacial - animations").
- Lenis (v1.3.23): A lightweight smooth-scrolling library. It hijacks the native browser scroll and interpolates it, feeding a buttery-smooth
scroll value into our GSAP ticker loop.
- ImagesLoaded: A utility library used in a Promise to prevent the animation from starting (and stuttering) before the heavy image assets are fully downloaded and rendered by the browser.