HeroParallax Hero
Full-viewport hero met twee lagen parallax scrolling. Achtergrond beweegt langzaam, voorgrond sneller. Diepte-effect zonder externe libs.
src/components/hero/HeroParallax.astro
---
interface Props {
headline: string;
sub?: string;
ctaLabel?: string;
ctaHref?: string;
secondaryLabel?: string;
secondaryHref?: string;
image1: string;
image2?: string;
overlay?: boolean;
}
const {
headline,
sub,
ctaLabel,
ctaHref = '#',
secondaryLabel,
secondaryHref = '#',
image1,
image2,
overlay = true,
} = Astro.props;
---
<section class="hpar__root" aria-label="Hero">
<!-- Background parallax layer -->
<div class="hpar__bg" id="hpar-bg">
<img
src={image1}
alt=""
class="hpar__bg-img"
loading="eager"
fetchpriority="high"
aria-hidden="true"
/>
{overlay && <div class="hpar__overlay" aria-hidden="true"></div>}
</div>
<!-- Foreground parallax card -->
{image2 && (
<div class="hpar__fg" id="hpar-fg" aria-hidden="true">
<img
src={image2}
alt=""
class="hpar__fg-img"
loading="eager"
fetchpriority="high"
/>
</div>
)}
<!-- Content -->
<div class="hpar__content" id="hpar-content">
<div class="hpar__inner">
<h1 class="hpar__headline" set:html={headline} />
{sub && <p class="hpar__sub">{sub}</p>}
{(ctaLabel || secondaryLabel) && (
<div class="hpar__actions">
{ctaLabel && (
<a href={ctaHref} class="hpar__cta hpar__cta--primary">
{ctaLabel}
</a>
)}
{secondaryLabel && (
<a href={secondaryHref} class="hpar__cta hpar__cta--ghost">
{secondaryLabel}
</a>
)}
</div>
)}
</div>
</div>
</section>
<script>
(function () {
const bg = document.getElementById('hpar-bg');
const fg = document.getElementById('hpar-fg');
const content = document.getElementById('hpar-content');
if (!bg) return;
let ticking = false;
let lastY = 0;
function applyParallax(scrollY: number) {
// Background moves at 0.3x scroll speed (slow, pulls away)
const bgOffset = scrollY * 0.3;
bg!.style.transform = `translateY(${bgOffset}px)`;
// Foreground moves at 0.6x scroll speed (faster = more depth)
if (fg) {
const fgOffset = scrollY * -0.6;
fg.style.transform = `translateY(${fgOffset}px)`;
}
}
function onScroll() {
lastY = window.scrollY;
if (!ticking) {
requestAnimationFrame(() => {
applyParallax(lastY);
ticking = false;
});
ticking = true;
}
}
// Entrance animation: observe content
if (content) {
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
entry.target.classList.add('hpar__content--visible');
observer.unobserve(entry.target);
}
});
},
{ threshold: 0.1 }
);
observer.observe(content);
}
window.addEventListener('scroll', onScroll, { passive: true });
// Run once on load
applyParallax(window.scrollY);
})();
</script>
<style>
:root {
--color-primary: #0a0a0a;
--color-accent: #6366f1;
--color-bg: #fff;
--color-muted: #6b7280;
--radius: 0.5rem;
}
.hpar__root {
position: relative;
width: 100%;
min-height: 100svh;
overflow: hidden;
display: flex;
align-items: center;
background: var(--color-primary);
}
/* Background layer */
.hpar__bg {
position: absolute;
inset: -15% 0;
will-change: transform;
z-index: 0;
}
.hpar__bg-img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.hpar__overlay {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.45);
}
/* Foreground parallax card */
.hpar__fg {
position: absolute;
right: 6%;
top: 50%;
transform: translateY(-50%);
width: clamp(260px, 32vw, 520px);
aspect-ratio: 4 / 5;
border-radius: calc(var(--radius) * 2);
overflow: hidden;
box-shadow:
0 32px 80px rgba(0, 0, 0, 0.5),
0 8px 24px rgba(0, 0, 0, 0.35);
will-change: transform;
z-index: 2;
}
.hpar__fg-img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
/* Content */
.hpar__content {
position: relative;
z-index: 3;
width: 100%;
padding: clamp(2rem, 6vw, 6rem) clamp(1.5rem, 8vw, 8rem);
opacity: 0;
transform: translateY(24px);
transition:
opacity 0.8s cubic-bezier(0.16, 1, 0.3, 1),
transform 0.8s cubic-bezier(0.16, 1, 0.3, 1);
}
.hpar__content--visible {
opacity: 1;
transform: translateY(0);
}
.hpar__inner {
max-width: min(640px, 55vw);
}
.hpar__headline {
font-size: clamp(3rem, 6vw, 5.5rem);
font-weight: 900;
line-height: 1.04;
letter-spacing: -0.03em;
color: #fff;
margin: 0 0 1.25rem;
}
.hpar__headline em {
font-style: normal;
color: var(--color-accent);
}
.hpar__sub {
font-size: clamp(1rem, 1.5vw, 1.25rem);
color: rgba(255, 255, 255, 0.72);
line-height: 1.65;
margin: 0 0 2.5rem;
max-width: 48ch;
}
.hpar__actions {
display: flex;
flex-wrap: wrap;
gap: 0.875rem;
align-items: center;
}
.hpar__cta {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.8125rem 2rem;
border-radius: var(--radius);
font-size: 0.9375rem;
font-weight: 600;
text-decoration: none;
transition:
opacity 0.2s,
transform 0.2s;
white-space: nowrap;
}
.hpar__cta:hover {
opacity: 0.88;
transform: translateY(-1px);
}
.hpar__cta--primary {
background: var(--color-accent);
color: #fff;
}
.hpar__cta--ghost {
background: transparent;
color: #fff;
border: 1.5px solid rgba(255, 255, 255, 0.4);
}
.hpar__cta--ghost:hover {
border-color: rgba(255, 255, 255, 0.7);
}
/* Entrance animation for content (CSS fallback for no-JS) */
@keyframes hpar-fadein {
from {
opacity: 0;
transform: translateY(24px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Mobile */
@media (max-width: 768px) {
.hpar__inner {
max-width: 100%;
}
.hpar__fg {
display: none;
}
.hpar__content {
padding: clamp(2rem, 8vw, 3rem) clamp(1.25rem, 5vw, 2rem);
}
}
@media (prefers-reduced-motion: reduce) {
* {
animation: none !important;
transition: none !important;
}
}
</style>
Props
| Prop | Type | Default | Beschrijving |
|---|---|---|---|
headline * | string | — | H1 — gebruik <em> voor accent |
image1 * | string | — | Achtergrond afbeelding (beweegt langzaam) |
image2 | string | — | Voorgrond kaart afbeelding (beweegt sneller) |
overlay | boolean | true | Donkere overlay over achtergrond |
sub | string | — | Ondertitel |
ctaLabel | string | — | Primaire CTA |
* = verplicht