When visitors arrive at a personal technical portfolio or academic website, they are typically greeted by a static hero section followed by a conventional chronological list. While this works well for highlighting the latest few entries, it leaves a vast repository of historical depth unseen. Over the past twenty years, my website has accumulated more than 170 distinct illustrated articles, open-source projects, university course curricula, consulting architectures, interactive physics simulations, and scientific research domains.
Traditional carousels or looping marquees suffer from well-known UX problems: they repeat the same dozen items in an endless loop, ignore what the visitor has already seen, and lack visual depth.
To solve this, we designed and engineered a living, 3-tier parallax Content Gallery directly beneath the homepage hero section. In this post, I want to explore the engineering and mathematical principles behind this component: from optical kinematics and row velocity ratios to zero on-screen duplication guarantees, dynamic archive pooling, and a two-factor probabilistic selection algorithm backed by localStorage.
TipLive Interactive Experience:
You can experience this dynamic 3-tier parallax gallery live in action directly below the hero section on the hackenberg.tech homepage.
1. Kinematics & Visual Hierarchy: Optical Motion Parallax
Rather than creating a single horizontal slider or a uniform grid, the gallery is structured into three distinct horizontal tiers moving continuously from right to left.
In human visual perception, motion parallax is a primary depth cue: objects closer to the observer sweep across the visual field at a higher angular velocity than objects situated further away. We applied this principle directly to the layout by coupling card scale, content fidelity, and linear speed:
| Tier | Role | Card Dimensions | Base Velocity | Relative Speed | Content Fidelity |
|---|---|---|---|---|---|
| Row 1 | Foreground | w-64 sm:w-72 md:w-80 (320px) | ~65 px/s | 100% | Title, Subtitle / Tagline, Directional Hint |
| Row 2 | Midground | w-52 sm:w-60 md:w-64 (256px) | ~42 px/s | ~65% | Compact Title, One-line Tagline |
| Row 3 | Background | w-40 sm:w-48 md:w-52 (208px) | ~26 px/s | ~40% | Subtle Title only |
Why this sizing and velocity ratio works
- Focus Separation: Row 1 captures primary attention with generous 16:9 canvas areas, crisp typography, and an interactive hover indicator. Row 2 offers density and scanning speed. Row 3 provides ambient, horizon-like movement that gives the entire viewport a cinematic sense of depth.
- Visual Rhythm: Because each tier travels at a different velocity (~65, ~42, and ~26 px/s), cards in different rows constantly shift relative to each other. Vertically aligned repetitions never form, creating an organic, fluid visual tapestry.
- Pure Artwork Aesthetic: We explicitly removed textual category badges, colored outlines, and decorative emoji icons from the cards. Each card relies on a uniform dark glass border (
border-white/10 light:border-slate-200/80), subtle hover elevation, and a dark scrim gradient. This ensures the illustrations and technical screenshots take center stage without visual clutter.
2. The Conveyor Belt: Zero-Jump Continuous DOM Recycling
Most looping web marquees duplicate their entire DOM tree (e.g., Sequence A and Sequence B) and run a static CSS @keyframes transform: translate3d(-50%, 0, 0) loop. While simple, this approach has fatal limitations:
- It can only ever loop the exact same fixed set of cards.
- It cannot inject fresh content dynamically from a large archive pool.
- Seamlessly updating items mid-flight without jarring visual shifts is notoriously difficult.
To overcome this, we implemented a continuous client-side DOM conveyor belt driven by requestAnimationFrame.
The Mathematics of Frictionless Recycling
Each track maintains a virtual horizontal coordinate track.x. On every frame, the scroller calculates the delta time and moves the track:
When the first child element of a track has completely scrolled past the left edge of the gallery viewport (cardRect.right < vpRect.left - 10):
- We measure the element’s full footprint:
- We instantly adjust the track position in the same synchronous frame execution:
- We select a new content item from our archive pool and re-hydrate the DOM node:
- Update
hrefanddata-id - Update
img.srcandimg.alt - Update
titleandtaglinetext nodes
- Update
- We move the element to the end of the flex container:
track.el.appendChild(firstCard);
Because removing firstCard from index 0 shifts the remaining flex children to the left by offset, and adding offset to track.x shifts the entire container to the right by the exact same amount, the two shifts cancel each other out with mathematical precision ( net change).
To the user’s eye, the visible cards continue moving at an uninterrupted 60/120 fps. Meanwhile, the recycled card quietly re-enters from far beyond the right viewport edge displaying an entirely fresh article.
3. Dynamic Archive Pooling & Zero On-Screen Duplication
Rather than selecting a dozen arbitrary favorites at build time, our Astro page template aggregates the entire site archive:
// src/pages/index.astro
const categoryBuckets = [
postGalleryItems, // 120+ illustrated technical articles
projectGalleryItems, // 12 standalone platforms & tools
courseGalleryItems, // 8 university courses
[...serviceGalleryItems, ...moduleGalleryItems], // Solution architectures
visualizationGalleryItems, // Graph network simulations
interestGalleryItems // Core research domains
];
Every asset is pre-optimized into WebP format (~640x360) via Astro’s getImage pipeline and packed into a compact JSON catalog embedded directly into the page (~25 KB).
The In-Use Set: Eliminating Duplicate Cards
One of the most distracting flaws in multi-track sliders is seeing the exact same article or project appear in two rows at the same time.
Our recycling engine eliminates this by maintaining an In-Use Set:
// Collect all IDs currently active across all tracks
const inUseIds = new Set<string>();
viewport.querySelectorAll<HTMLElement>('.gallery-card[data-id]').forEach(c => {
if (c !== firstCard && c.dataset.id) {
inUseIds.add(c.dataset.id);
}
});
// Candidate pool strictly excludes anything currently on screen or queued
const candidates = catalog.filter(item => !inUseIds.has(item.id));
With approximately 45 total cards distributed across the three tracks and a catalog of over 170 items, the candidate pool always retains more than 120 distinct items. Duplicate cards across rows are mathematically impossible.
4. The Two-Factor Probabilistic Selection Engine
How should the engine choose which item to display next when a card is recycled? A purely uniform random selection would be naive:
- It would frequently re-display items the visitor saw just thirty seconds ago.
- It would fail to prioritize newly published research and modern platforms over articles written in 2009.
- It would offer no guarantee that unvisited items are ever discovered.
To resolve this, we engineered a two-factor weighted probability algorithm:
Factor 1: Publication Recency Decay
We want to highlight newer articles while keeping classic foundational posts discoverable. We use a graceful rational decay function:
- A post published in 2026 () receives .
- An article from 2022 () receives .
- A foundational post from 2009 () receives .
Recent content is approximately more likely to appear, yet the 2009 archive is never starved.
Factor 2: LocalStorage LRU Display History
To give the gallery a “memory”, the client records impression timestamps in localStorage under gh_gallery_impressions:
let seenLedger: Record<string, number> = loadSeenLedger();
When calculating candidate weights, the display factor acts as an intelligent governor:
- Unseen Content Boost (): If an item has never been displayed to this user (
!seenLedger[id]), its weight is multiplied by . Unread content is propelled to the front of the queue. - Strict Cooldown Penalty (): If an item was displayed within the last 12 minutes, its weight drops to (a 95% suppression). This ensures an item that just scrolled off the left edge will not reappear a minute later.
- Smooth Weight Recovery: As time passes beyond 12 minutes, the suppression decays linearly back to baseline () over the following 48 minutes:
(Rationale: Average web session durations typically range from 2 to 5 minutes. A 12-minute strict window guarantees zero visual duplication within a single session, while the 48-minute linear recovery smoothly reintroduces older archive items if a visitor returns within the same hour).
function pickNextItem(
catalog: GalleryItem[],
inUseIds: Set<string>,
seenLedger: Record<string, number>
): GalleryItem {
let candidates = catalog.filter(item => !inUseIds.has(item.id));
if (candidates.length === 0) candidates = catalog;
const now = Date.now();
const weights: number[] = [];
let totalWeight = 0;
for (const item of candidates) {
const ageYears = Math.max(0, (now - item.pubDate) / (1000 * 60 * 60 * 24 * 365.25));
const contentFactor = 1 / (1 + 0.25 * ageYears);
const lastSeen = seenLedger[item.id];
let displayFactor = 1.0;
if (!lastSeen) {
displayFactor = 12.0; // Unseen boost
} else {
const minutesSinceSeen = (now - lastSeen) / 60000;
if (minutesSinceSeen < 12) {
displayFactor = 0.05; // Cooldown
} else {
displayFactor = Math.min(1.0, 0.05 + 0.95 * ((minutesSinceSeen - 12) / 48));
}
}
const weight = contentFactor * displayFactor;
weights.push(weight);
totalWeight += weight;
}
// Weighted roulette wheel selection
let random = Math.random() * totalWeight;
for (let i = 0; i < candidates.length; i++) {
random -= weights[i];
if (random <= 0 || i === candidates.length - 1) {
return candidates[i];
}
}
return candidates[0];
}
The Cold-Start & Reload Paradox: Full Client-Side Rehydration
A subtle architectural challenge arises when marrying a client-side LRU tracking engine with a static site generator (SSG):
- The Stateless Build: At build time, Astro compiles the initial HTML markup statically, populating the 45 initial card slots with default catalog items. The static compiler has no access to a user’s browser storage.
- The Reload Flaw: If client-side JavaScript only invokes
pickNextItem()when an existing card scrolls off the left screen edge, a visitor will see the exact same 45 static items every time they reload or revisit the homepage. Even though subsequent cards would eventually be personalized, the initial visual impression on every reload would remain identical.
To solve this, we implemented synchronous full client-side rehydration on mount:
// src/components/ImageGalleryMarquee.astro
function setupGalleryEngine() {
inMemorySeenLedger = loadSeenLedger();
// Full Client-Side Rehydration across all 3 tracks
const inUseIds = new Set<string>();
const initialCards = viewport.querySelectorAll<HTMLElement>('.gallery-card');
initialCards.forEach(card => {
const nextItem = pickNextItem(catalog, inUseIds, inMemorySeenLedger);
inUseIds.add(nextItem.id);
hydrateCard(card, nextItem);
recordSeen(nextItem.id);
});
// Proceed with ultra-wide buffer checks and animation loop...
}
Why this architecture is optimal:
- Zero Repetition on Reload: When the page reloads, the items displayed in the user’s previous session are already recorded in
localStorage. The selection algorithm penalizes them with the cooldown, while unread content receives the boost. The initial frame immediately renders completely fresh, unseen content. - Zero Cumulative Layout Shift (): Because each card possesses strictly constrained dimensions (
aspect-video, fixed responsive width containers) and a dark glass aesthetic, swappingimg.src,href, and text nodes occurs in under 2 milliseconds without shifting the layout by a single pixel. - Flawless Search & AI Discovery (SEO, GEO, AEO): Stateless web crawlers (Googlebot, Bingbot) and generative AI engines (Perplexity, SearchGPT) receive 45 semantic, pre-rendered
<a>and<img>tags directly from the static HTML with zero reliance on client JavaScript execution.
5. Performance, Ergonomics, and Accessibility
A continuous visual animation must be respectful of device resources and user control:
- Hover & Keyboard Focus Pausing: Hovering anywhere inside the viewport or navigating cards via
Tab(:focus-within) immediately pauses the animation so users can read descriptions or click links without chasing moving targets. - Accessible Control Button: An explicit Pause/Resume button with
aria-pressedstates allows users who prefer static layouts to freeze the gallery permanently. - Tab Visibility Throttling: When the user switches tabs,
document.visibilityState === 'hidden'suspends the loop. Upon returning, delta time is reset to avoid large calculation jumps. prefers-reduced-motionCompliance: If the user has requested reduced motion in their operating system, the JavaScript loop is bypassed entirely. The CSS switches the tracks into native horizontal snap-scrolling containers:
@media (prefers-reduced-motion: reduce) {
.gallery-viewport {
mask-image: none;
-webkit-mask-image: none;
}
.gallery-track {
transform: none !important;
overflow-x: auto;
scroll-snap-type: x mandatory;
width: 100%;
padding-inline: 1rem;
}
.gallery-card {
scroll-snap-align: start;
}
}
- Edge Masking: Subtle CSS gradient masks (
mask-image: linear-gradient(to right, transparent 0%, black 5%, black 95%, transparent 100%)) gracefully feather the cards into the viewport borders on both edges, preventing harsh clipping.
Conclusion
Modern portfolio and technical websites do not need to choose between static, lifeless layouts and resource-heavy, repetitive 3D canvases.
By marrying optical motion kinematics with a mathematically seamless DOM recycling loop, and driving content selection via client-side LRU display history, we created a gallery that feels perpetually fresh, never repeats on screen, and brings twenty years of engineering projects into view effortlessly.