Zoeken...  ⌘K GitHub

ProcessTimeline Sections

Verticale timeline met centrale lijn, genummerde nodes en afwisselende kaarten. IntersectionObserver reveal animaties.

/process-timeline
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