Parallax Columns: A No-JS Background Effect for the Hero
Published: September 9, 2026
⏱️ 5 min read | 📝 960 words
The Hero section of this site used to sit on a static background image. It looked fine, but it felt flat. I'd been admiring the vertical-bar parallax effect from aepicos' CodePen — a set of tall rectangles gliding upward at different speeds as you scroll. I wanted that same layered depth as the backdrop for my headline, also with zero JavaScript.
Here's how I built ParallaxColumns, what I tweaked along the way, and why it now lives behind the Hero instead of a static image.
The one-line pitch
<ParallaxColumns count={20} mode="ambient" title="I'm Nils Sanderson" /> is now the entire background of my hero. The bars rise on a continuous loop, each one at its own speed and opacity, while my name and intro sit on top with a gradient text treatment.
Original inspiration: CodePen Home Avengers: Infinity War — a CSS parallax experiment by aepicos on CodePen.
How the columns are generated
Rather than hand-placing a dozen <div>s, the component generates them up front from a single count prop. Each column gets a deterministic position, width, height, hue, opacity, and "travel distance" derived from its index:
---
interface Props {
count?: number;
mode?: 'scroll' | 'ambient';
title?: string;
description?: string;
caption?: string;
}
const {
count = 10,
title = 'Parallax Columns',
description = 'Experience the magic of parallax scrolling',
caption = 'keep scrolling',
mode = 'scroll',
} = Astro.props;
const n = Math.min(24, Math.max(4, Math.round(count ?? 10)));
const columns = Array.from({ length: n }, (_, i) => {
const t = n > 1 ? i / (n - 1) : 0.5;
const jitter = (i * 7) % 9 - 4;
return {
left: Math.round(4 + t * 88 + jitter),
width: 3 + (i * 5) % 5,
height: 40 + (i * 13) % 55,
travel: 90 + Math.round(t * 460),
hue: (206 + i * 18) % 360,
opacity: Math.round((0.3 + (i % 4) * 0.14) * 100) / 100,
cap: Math.max(0.55, 1 - Math.abs(t - 0.5) * 0.9),
duration: `${14 + (i * 17) % 24}s`,
delay: `-${(i * 11) % 40 / 10}s`,
};
});
---Those values become CSS custom properties on each bar, so the animation logic stays entirely in the stylesheet:
<div
class="parallax__bar"
style={`left:${c.left}%;width:${c.width}vw;height:${c.height}vh;` +
`--travel:${c.travel}px;--hue:${c.hue};--opacity:${c.opacity};` +
`--cap:${c.cap};--duration:${c.duration};--delay:${c.delay};`}
></div>Two modes, one component
The original CodePen relies on a scroll event. I wanted a version that worked as a full-bleed hero backdrop with no JS, so the component ships with two modes:
mode="ambient" — a seeded, continuous loop. Each bar rises at its own speed (--duration) with a staggered negative delay (--delay), so the strip never looks robotic and never needs a scroll listener.
.parallax[data-mode="ambient"] .parallax__bar {
animation: prlx-loop linear infinite;
animation-duration: var(--duration);
animation-delay: var(--delay);
}
@keyframes prlx-loop {
0% {
transform: translateY(calc(var(--travel, 90px) * 0.6));
opacity: 0;
}
20% {
opacity: calc(var(--opacity) * var(--cap));
}
80% {
opacity: calc(var(--opacity) * var(--cap) * 0.4);
}
100% {
transform: translateY(calc(var(--travel, 90px) * -1));
opacity: 0;
}
}mode="scroll" — the closer match to the original pen, but driven by native scroll-driven animations (animation-timeline: scroll(root)) instead of JS. Each bar still travels a different --travel distance, so you get the true offset-parallax feel as you scroll the page:
.parallax[data-mode="scroll"] .parallax__bar {
animation-name: prlx-rise;
animation-timeline: scroll(root);
animation-duration: auto;
animation-range: 0% var(--travel-range, 100%);
}
@keyframes prlx-rise {
from { transform: translateY(0); }
to { transform: translateY(calc(var(--travel, 90px) * -1)); }
}Browser support for scroll-driven animations is good (Chrome/Edge 124+, Safari 18+, recent Firefox), and on anything older the bars simply render as a static decorative strip — the page still works, it just doesn't move.
Tweaks I made along the way
- Deterministic geometry. My first pass used
Math.random()per column. That looked great in isolation but reshuffled on every hot reload and every build — different bars, different vibes. Switching to index-derived values keeps the composition stable between builds while still looking organic. descriptionalongsidetitle. I swapped the original standalonecaptionfor a dedicateddescriptionline under the title and keptcaptionas an optional bottom hint. In the Hero,caption=""keeps things clean while the tagline sits directly under the name.90vhdefault height. As a full hero backdrop I shrank the default from220vhto90vh, with100vwwidth so it bleeds edge-to-edge with no horizontal scrollbar.- Always respect reduced motion. Both modes are wrapped in
@media (prefers-reduced-motion: reduce)so the bars freeze and the section collapses to a calm static backdrop.
Wiring it into the Hero
The Hero is dramatically simpler now — the old static <img> background is commented out and replaced with the component:
---
import ParallaxColumns from "./ParallaxColumns.astro";
---
<section class="relative min-h-[90vh] flex items-center justify-center overflow-hidden">
<ParallaxColumns
count={20}
mode="ambient"
title="I'm Nils Sanderson"
description="I plan on rambling about my experiences and experiments from time to time. I am taking a professional break from the tech industry while I explore new interests and opportunities (and see how things develop no pun intended)."
caption=""
/>
</section>The bars render behind (and pointer-events: none keeps them from stealing clicks), the name and intro are centered via the caption grid, and there's no JavaScript in the bundle at all.
Verdict
Recreating the CodePen effect as a standalone Astro component was a great way to make the Hero feel alive without a single <script> tag. Between the ambient loop and the scroll-driven mode, I get the layered-depth look I was after, a stable build output, and a fallback that never breaks the page.
If you want to see it in the wild, it's running at the top of the homepage right now — just scroll slowly and watch the bars glide past.