Zoeken...  ⌘K GitHub

FeaturesShowcase Sections

Interactieve feature showcase: scrollbare lijst links, sticky preview panel rechts. Wisselt automatisch of via klik.

/features-showcase
src/components/sections/FeaturesShowcase.astro
---
/**
 * FeaturesShowcase
 * Interactieve feature showcase: links scrollbare lijst met tab-achtige selectie,
 * rechts grote animated preview/afbeelding die wisselt. Sticky scroll effect.
 * Puur CSS — geen JS vereist voor de basis layout.
 */
interface Feature {
  number?: string;
  title: string;
  description: string;
  imageSrc?: string;
  imageAlt?: string;
  tag?: string;
}

interface Props {
  preHeadline?: string;
  headline: string;
  sub?: string;
  features: Feature[];
}

const {
  preHeadline,
  headline,
  sub,
  features = [],
} = Astro.props;
---

<section class="fs" data-component="features-showcase">
  <div class="fs__inner">

    <!-- Header -->
    <div class="fs__header">
      {preHeadline && <p class="fs__pre">{preHeadline}</p>}
      <h2 class="fs__headline" set:html={headline} />
      {sub && <p class="fs__sub">{sub}</p>}
    </div>

    <!-- Showcase grid -->
    <div class="fs__body">
      <!-- Feature list (left) -->
      <div class="fs__list">
        {features.map((f, i) => (
          <div class="fs__item" data-index={i}>
            <div class="fs__item-header">
              {f.number && <span class="fs__num">{f.number}</span>}
              {f.tag && <span class="fs__tag">{f.tag}</span>}
            </div>
            <h3 class="fs__title">{f.title}</h3>
            <p class="fs__desc">{f.description}</p>
            <div class="fs__progress"><div class="fs__progress-bar"></div></div>
          </div>
        ))}
      </div>

      <!-- Preview panel (right, sticky) -->
      <div class="fs__preview-wrap">
        <div class="fs__preview-stack">
          {features.map((f, i) => (
            <div class="fs__preview-slide" data-slide={i} class:list={[{ 'fs__preview-slide--active': i === 0 }]}>
              {f.imageSrc ? (
                <img src={f.imageSrc} alt={f.imageAlt ?? f.title} class="fs__preview-img" loading="lazy" />
              ) : (
                <div class="fs__preview-placeholder">
                  <span class="fs__preview-num">{f.number ?? String(i + 1).padStart(2, '0')}</span>
                  <span class="fs__preview-title-lg">{f.title}</span>
                </div>
              )}
            </div>
          ))}
        </div>
      </div>
    </div>
  </div>
</section>

<script>
  const section = document.querySelector('[data-component="features-showcase"]');
  if (section) {
    const items = section.querySelectorAll<HTMLElement>('.fs__item');
    const slides = section.querySelectorAll<HTMLElement>('.fs__preview-slide');

    function activate(index: number) {
      items.forEach((item, i) => {
        item.classList.toggle('fs__item--active', i === index);
      });
      slides.forEach((slide, i) => {
        slide.classList.toggle('fs__preview-slide--active', i === index);
      });
    }

    // Click to switch
    items.forEach((item, i) => {
      item.addEventListener('click', () => activate(i));
    });

    // IntersectionObserver for scroll-based activation
    const observer = new IntersectionObserver(
      (entries) => {
        entries.forEach(entry => {
          if (entry.isIntersecting) {
            const index = Number((entry.target as HTMLElement).dataset.index);
            activate(index);
          }
        });
      },
      { threshold: 0.6 }
    );

    items.forEach(item => observer.observe(item));
    activate(0);
  }
</script>

<style>
  .fs {
    padding: 6rem 1.5rem;
    background: var(--color-bg, #fff);
    overflow: hidden;
  }

  .fs__inner { max-width: 1200px; margin: 0 auto; }

  /* Header */
  .fs__header {
    text-align: center;
    max-width: 640px;
    margin: 0 auto 5rem;
  }

  .fs__pre {
    font-size: 0.75rem;
    font-weight: 700;
    letter-spacing: 0.12em;
    text-transform: uppercase;
    color: var(--color-accent, #6366f1);
    margin-bottom: 1rem;
  }

  .fs__headline {
    font-size: clamp(2rem, 4vw, 3.25rem);
    font-weight: 800;
    line-height: 1.1;
    letter-spacing: -0.035em;
    color: var(--color-primary, #0a0a0a);
    margin-bottom: 1rem;
  }

  .fs__headline :global(em) {
    font-style: normal;
    color: var(--color-accent, #6366f1);
  }

  .fs__sub {
    font-size: 1.0625rem;
    line-height: 1.65;
    color: var(--color-muted, #6b7280);
  }

  /* Body grid */
  .fs__body {
    display: grid;
    grid-template-columns: 1fr 1fr;
    gap: 4rem;
    align-items: start;
  }

  /* Feature list */
  .fs__list {
    display: flex;
    flex-direction: column;
    gap: 0;
  }

  .fs__item {
    padding: 2rem 0;
    border-top: 1px solid rgba(0,0,0,0.07);
    cursor: pointer;
    transition: padding 0.3s;
  }

  .fs__item:last-child { border-bottom: 1px solid rgba(0,0,0,0.07); }

  .fs__item--active {
    padding: 2rem 1.5rem;
    background: var(--color-bg, #fff);
    border-radius: 0.75rem;
    box-shadow: 0 2px 24px rgba(0,0,0,0.06);
    border-top-color: transparent;
  }

  .fs__item-header {
    display: flex;
    align-items: center;
    gap: 0.75rem;
    margin-bottom: 0.625rem;
  }

  .fs__num {
    font-size: 0.6875rem;
    font-weight: 700;
    letter-spacing: 0.1em;
    color: var(--color-muted, #6b7280);
    font-feature-settings: "tnum";
  }

  .fs__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;
  }

  .fs__title {
    font-size: 1.25rem;
    font-weight: 700;
    letter-spacing: -0.02em;
    color: var(--color-primary, #0a0a0a);
    margin-bottom: 0.5rem;
  }

  .fs__desc {
    font-size: 0.9375rem;
    line-height: 1.65;
    color: var(--color-muted, #6b7280);
    max-height: 0;
    overflow: hidden;
    transition: max-height 0.35s cubic-bezier(0.22,1,0.36,1), opacity 0.3s;
    opacity: 0;
  }

  .fs__item--active .fs__desc {
    max-height: 160px;
    opacity: 1;
  }

  /* Progress bar */
  .fs__progress {
    height: 2px;
    background: rgba(0,0,0,0.06);
    border-radius: 1px;
    margin-top: 1rem;
    overflow: hidden;
    opacity: 0;
    transition: opacity 0.3s;
  }

  .fs__item--active .fs__progress { opacity: 1; }

  .fs__progress-bar {
    height: 100%;
    background: var(--color-accent, #6366f1);
    border-radius: 1px;
    width: 0%;
  }

  .fs__item--active .fs__progress-bar {
    animation: fs-progress 5s linear forwards;
  }

  @keyframes fs-progress {
    from { width: 0%; }
    to   { width: 100%; }
  }

  /* Preview panel */
  .fs__preview-wrap {
    position: sticky;
    top: 2rem;
  }

  .fs__preview-stack {
    position: relative;
    width: 100%;
    aspect-ratio: 4/3;
    border-radius: 1rem;
    overflow: hidden;
    background: #f3f4f6;
    box-shadow: 0 8px 48px rgba(0,0,0,0.1);
  }

  .fs__preview-slide {
    position: absolute;
    inset: 0;
    opacity: 0;
    transform: scale(0.97) translateY(8px);
    transition: opacity 0.5s cubic-bezier(0.22,1,0.36,1), transform 0.5s cubic-bezier(0.22,1,0.36,1);
    pointer-events: none;
  }

  .fs__preview-slide--active {
    opacity: 1;
    transform: scale(1) translateY(0);
    pointer-events: auto;
  }

  .fs__preview-img {
    width: 100%;
    height: 100%;
    object-fit: cover;
  }

  .fs__preview-placeholder {
    width: 100%;
    height: 100%;
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    gap: 1rem;
    background: linear-gradient(135deg, #f0f1ff, #e8eaff);
    padding: 2rem;
    text-align: center;
  }

  .fs__preview-num {
    font-size: 4rem;
    font-weight: 900;
    color: var(--color-accent, #6366f1);
    opacity: 0.2;
    line-height: 1;
    letter-spacing: -0.05em;
  }

  .fs__preview-title-lg {
    font-size: 1.5rem;
    font-weight: 700;
    color: var(--color-primary, #0a0a0a);
    letter-spacing: -0.02em;
  }

  @media (max-width: 900px) {
    .fs__body { grid-template-columns: 1fr; }
    .fs__preview-wrap { position: relative; top: 0; order: -1; }
    .fs__preview-stack { aspect-ratio: 16/9; }
  }
</style>

Props

Prop Type Default Beschrijving
features * Feature[] Feature items met title, description en optioneel imageSrc
preHeadline string Label boven headline
headline string Sectie headline
sub string Ondertitel

* = verplicht