ProcessTimeline Sections
Verticale timeline met centrale lijn, genummerde nodes en afwisselende kaarten. IntersectionObserver reveal animaties.
src/components/sections/ProcessTimeline.astro
---
/**
* ProcessTimeline
* Vertikale timeline: stap-nummers op centrale lijn, afwisselend links/rechts.
* Op mobile: lineair verticaal. IntersectionObserver voor reveal.
* Puur CSS animaties.
*/
interface Step {
number?: string;
title: string;
description: string;
tag?: string;
icon?: string;
duration?: string;
}
interface Props {
preHeadline?: string;
headline: string;
sub?: string;
steps: Step[];
variant?: 'alternating' | 'left';
}
const {
preHeadline,
headline,
sub,
steps = [],
variant = 'alternating',
} = Astro.props;
---
<section class:list={['pt', `pt--${variant}`]} data-component="process-timeline">
<div class="pt__inner">
<div class="pt__header">
{preHeadline && <p class="pt__pre">{preHeadline}</p>}
<h2 class="pt__headline" set:html={headline} />
{sub && <p class="pt__sub">{sub}</p>}
</div>
<div class="pt__timeline">
<!-- Central line -->
<div class="pt__line" aria-hidden="true">
<div class="pt__line-fill"></div>
</div>
{steps.map((step, i) => (
<div
class="pt__step"
data-step={i}
style={`--step-delay:${i * 0.12}s`}
>
<!-- Node on the line -->
<div class="pt__node">
<span class="pt__node-num">
{step.number ?? String(i + 1).padStart(2, '0')}
</span>
</div>
<!-- Content card -->
<div class="pt__card">
{(step.tag || step.duration) && (
<div class="pt__card-meta">
{step.tag && <span class="pt__tag">{step.tag}</span>}
{step.duration && (
<span class="pt__duration">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
<path d="M12 6v6l4 2"/>
</svg>
{step.duration}
</span>
)}
</div>
)}
{step.icon && (
<div class="pt__card-icon" set:html={step.icon} />
)}
<h3 class="pt__step-title">{step.title}</h3>
<p class="pt__step-desc">{step.description}</p>
</div>
</div>
))}
</div>
</div>
</section>
<script>
const timelines = document.querySelectorAll('[data-component="process-timeline"]');
timelines.forEach(section => {
const steps = section.querySelectorAll<HTMLElement>('.pt__step');
const lineFill = section.querySelector<HTMLElement>('.pt__line-fill');
const observer = new IntersectionObserver(
entries => {
entries.forEach(entry => {
if (entry.isIntersecting) {
entry.target.classList.add('pt__step--visible');
observer.unobserve(entry.target);
}
});
},
{ threshold: 0.25 }
);
steps.forEach(step => observer.observe(step));
// Animate line fill based on scroll
if (lineFill) {
const updateLine = () => {
const rect = section.getBoundingClientRect();
const progress = Math.max(0, Math.min(1,
(window.innerHeight - rect.top) / (rect.height + window.innerHeight)
));
lineFill.style.transform = `scaleY(${progress})`;
};
window.addEventListener('scroll', updateLine, { passive: true });
updateLine();
}
});
</script>
<style>
.pt {
padding: 6rem 1.5rem;
background: var(--color-bg, #fff);
overflow: hidden;
}
.pt__inner { max-width: 900px; margin: 0 auto; }
.pt__header {
text-align: center;
max-width: 600px;
margin: 0 auto 5rem;
}
.pt__pre {
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--color-accent, #6366f1);
margin-bottom: 0.875rem;
}
.pt__headline {
font-size: clamp(1.75rem, 3.5vw, 3rem);
font-weight: 800;
letter-spacing: -0.035em;
line-height: 1.1;
color: var(--color-primary, #0a0a0a);
margin-bottom: 0.875rem;
}
.pt__headline :global(em) {
font-style: normal;
color: var(--color-accent, #6366f1);
}
.pt__sub {
font-size: 1rem;
line-height: 1.65;
color: var(--color-muted, #6b7280);
}
/* === TIMELINE === */
.pt__timeline {
position: relative;
display: flex;
flex-direction: column;
gap: 0;
}
/* Central line */
.pt__line {
position: absolute;
left: 50%;
top: 0;
bottom: 0;
width: 1px;
background: rgba(0,0,0,0.07);
transform: translateX(-50%);
overflow: hidden;
}
.pt__line-fill {
position: absolute;
inset: 0;
background: linear-gradient(to bottom, var(--color-accent, #6366f1), #a78bfa);
transform: scaleY(0);
transform-origin: top;
transition: transform 0.1s;
}
/* === STEP === */
.pt__step {
display: grid;
grid-template-columns: 1fr 56px 1fr;
align-items: center;
gap: 2rem;
padding: 2.5rem 0;
opacity: 0;
transition: opacity 0.6s var(--step-delay, 0s) cubic-bezier(0.22,1,0.36,1),
transform 0.6s var(--step-delay, 0s) cubic-bezier(0.22,1,0.36,1);
}
.pt--alternating .pt__step:nth-child(odd) {
transform: translateX(-30px);
}
.pt--alternating .pt__step:nth-child(even) {
transform: translateX(30px);
}
.pt--left .pt__step {
transform: translateX(-20px);
}
.pt__step--visible {
opacity: 1 !important;
transform: none !important;
}
/* Node */
.pt__node {
width: 56px;
height: 56px;
background: var(--color-primary, #0a0a0a);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
z-index: 1;
border: 3px solid var(--color-bg, #fff);
box-shadow: 0 0 0 2px var(--color-primary, #0a0a0a);
transition: background 0.3s;
}
.pt__step--visible .pt__node {
background: var(--color-accent, #6366f1);
box-shadow: 0 0 0 2px var(--color-accent, #6366f1), 0 0 24px rgba(99,102,241,0.2);
}
.pt__node-num {
font-size: 0.75rem;
font-weight: 800;
color: #fff;
letter-spacing: -0.02em;
font-feature-settings: "tnum";
}
/* Card */
.pt__card {
background: var(--color-bg, #fff);
border: 1px solid rgba(0,0,0,0.07);
border-radius: 1rem;
padding: 1.75rem;
box-shadow: 0 2px 16px rgba(0,0,0,0.04);
transition: box-shadow 0.3s, border-color 0.3s;
}
.pt__step--visible .pt__card {
box-shadow: 0 4px 24px rgba(0,0,0,0.08);
}
.pt__card:hover {
border-color: rgba(99,102,241,0.2);
box-shadow: 0 8px 32px rgba(99,102,241,0.08);
}
/* Alternating: odd steps → card on left, even → card on right */
.pt--alternating .pt__step:nth-child(odd) .pt__card {
grid-column: 1;
order: -1;
text-align: right;
}
.pt--alternating .pt__step:nth-child(odd) .pt__card-meta { justify-content: flex-end; }
.pt--alternating .pt__step:nth-child(odd) .pt__card-icon { margin-left: auto; }
/* Left variant — all cards on right */
.pt--left .pt__step {
grid-template-columns: 56px 1fr;
gap: 1.5rem;
}
.pt--left .pt__line {
left: 28px;
transform: none;
}
.pt__card-meta {
display: flex;
align-items: center;
gap: 0.625rem;
margin-bottom: 0.875rem;
}
.pt__tag {
font-size: 0.6875rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
background: rgba(99,102,241,0.08);
color: var(--color-accent, #6366f1);
padding: 0.1875rem 0.5rem;
border-radius: 999px;
}
.pt__duration {
display: flex;
align-items: center;
gap: 0.25rem;
font-size: 0.75rem;
color: var(--color-muted, #6b7280);
}
.pt__card-icon {
width: 32px;
height: 32px;
color: var(--color-accent, #6366f1);
margin-bottom: 0.875rem;
}
.pt__step-title {
font-size: 1.125rem;
font-weight: 700;
letter-spacing: -0.02em;
color: var(--color-primary, #0a0a0a);
margin-bottom: 0.5rem;
}
.pt__step-desc {
font-size: 0.9375rem;
line-height: 1.65;
color: var(--color-muted, #6b7280);
}
@media (prefers-reduced-motion: reduce) {
.pt__step { opacity: 1 !important; transform: none !important; transition: none !important; }
}
@media (max-width: 768px) {
.pt__timeline { padding-left: 2rem; }
.pt__line { left: 0; }
.pt--alternating .pt__step,
.pt__step {
grid-template-columns: 40px 1fr;
gap: 1rem;
}
.pt__node { width: 40px; height: 40px; }
.pt--alternating .pt__step:nth-child(odd) .pt__card {
grid-column: auto;
order: 0;
text-align: left;
}
.pt--alternating .pt__step:nth-child(odd) .pt__card-meta { justify-content: flex-start; }
.pt--alternating .pt__step:nth-child(odd) .pt__card-icon { margin-left: 0; }
.pt--alternating .pt__step:nth-child(odd) { transform: translateX(-20px); }
.pt--alternating .pt__step:nth-child(even) { transform: translateX(-20px); }
}
</style>
Props
| Prop | Type | Default | Beschrijving |
|---|---|---|---|
steps * | Step[] | — | Stappen met title, description en optioneel tag/duration |
variant | 'alternating' | 'left' | 'alternating' | Afwisselend of allemaal links |
headline | string | — | Sectie headline |
* = verplicht