Zoeken...  ⌘K GitHub

NumberedList list

Gestyled genummerd lijst. 3 varianten: standaard, groot decoratief nummer en inline. Verbindingslijn in stack layout.

/numbered-list
src/components/list/NumberedList.astro
---
interface Props {
  eyebrow?: string;
  headline?: string;
  items: {
    number?: string;
    title: string;
    description: string;
    image?: string;
  }[];
  variant?: 'default' | 'large' | 'inline';
  layout?: 'stack' | 'grid';
}

const {
  eyebrow,
  headline,
  items = [],
  variant = 'default',
  layout = 'stack',
} = Astro.props;

function autoNumber(i: number) {
  return String(i + 1).padStart(2, '0');
}
---

<section class={`nml__section nml__variant-${variant} nml__layout-${layout}`}>
  {(eyebrow || headline) && (
    <div class="nml__header">
      {eyebrow && <p class="nml__eyebrow">{eyebrow}</p>}
      {headline && <h2 class="nml__headline">{headline}</h2>}
    </div>
  )}

  <ol class="nml__list" role="list">
    {items.map((item, i) => (
      <li class="nml__item" style={`--delay: ${i * 100}ms; --index: ${i}`}>
        {variant === 'large' ? (
          <div class="nml__large-wrap">
            <span class="nml__large-num" aria-hidden="true">{item.number ?? autoNumber(i)}</span>
            <div class="nml__text">
              <p class="nml__title">{item.title}</p>
              <p class="nml__desc">{item.description}</p>
            </div>
          </div>
        ) : variant === 'inline' ? (
          <div class="nml__inline-wrap">
            <p class="nml__inline-heading">
              <span class="nml__num-circle">{item.number ?? autoNumber(i)}</span>
              <span class="nml__title">{item.title}</span>
            </p>
            <p class="nml__desc">{item.description}</p>
            {item.image && <img src={item.image} alt={item.title} class="nml__img" loading="lazy" />}
          </div>
        ) : (
          /* default */
          <div class="nml__default-wrap">
            <span class="nml__num-circle" aria-hidden="true">{item.number ?? autoNumber(i)}</span>
            {layout === 'stack' && i < items.length - 1 && (
              <span class="nml__connector" aria-hidden="true" />
            )}
            <div class="nml__text">
              <p class="nml__title">{item.title}</p>
              <p class="nml__desc">{item.description}</p>
              {item.image && <img src={item.image} alt={item.title} class="nml__img" loading="lazy" />}
            </div>
          </div>
        )}
      </li>
    ))}
  </ol>
</section>

<script>
  const lists = document.querySelectorAll<HTMLElement>('.nml__list');

  lists.forEach((list) => {
    const items = list.querySelectorAll<HTMLElement>('.nml__item');

    const observer = new IntersectionObserver(
      (entries) => {
        entries.forEach((entry) => {
          if (entry.isIntersecting) {
            (entry.target as HTMLElement).classList.add('nml__item--visible');
            observer.unobserve(entry.target);
          }
        });
      },
      { threshold: 0.1, rootMargin: '0px 0px -40px 0px' }
    );

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

<style>
  :root {
    --color-primary: #0a0a0a;
    --color-accent: #6366f1;
    --color-bg: #fff;
    --color-muted: #6b7280;
    --radius: 0.5rem;
  }

  .nml__section {
    padding: 5rem 1.5rem;
    max-width: 56rem;
    margin-inline: auto;
  }

  /* Header */
  .nml__header {
    margin-bottom: 3rem;
  }

  .nml__eyebrow {
    font-size: 0.75rem;
    font-weight: 600;
    letter-spacing: 0.1em;
    text-transform: uppercase;
    color: var(--color-accent);
    margin: 0 0 0.75rem;
  }

  .nml__headline {
    font-size: clamp(1.75rem, 3vw, 2.5rem);
    font-weight: 800;
    color: var(--color-primary);
    margin: 0;
    line-height: 1.15;
  }

  /* List */
  .nml__list {
    list-style: none;
    padding: 0;
    margin: 0;
    display: flex;
    flex-direction: column;
    gap: 0;
  }

  .nml__layout-grid .nml__list {
    display: grid;
    grid-template-columns: repeat(3, 1fr);
    gap: 2rem;
  }

  /* Animation */
  .nml__item {
    opacity: 0;
    transform: translateY(18px);
  }

  .nml__item--visible {
    animation: nml-fadein 0.5s ease forwards;
    animation-delay: var(--delay, 0ms);
  }

  @keyframes nml-fadein {
    to {
      opacity: 1;
      transform: translateY(0);
    }
  }

  /* ── DEFAULT variant ── */
  .nml__default-wrap {
    display: flex;
    align-items: flex-start;
    gap: 1.25rem;
    position: relative;
    padding-bottom: 2.5rem;
  }

  .nml__layout-grid .nml__default-wrap {
    flex-direction: column;
    padding-bottom: 0;
    gap: 1rem;
  }

  /* Number circle */
  .nml__num-circle {
    display: flex;
    align-items: center;
    justify-content: center;
    width: 40px;
    height: 40px;
    border-radius: 50%;
    background: var(--color-accent);
    color: #fff;
    font-size: 0.8125rem;
    font-weight: 900;
    flex-shrink: 0;
    font-variant-numeric: tabular-nums;
    position: relative;
    z-index: 1;
  }

  /* Connecting line (stack layout only) */
  .nml__connector {
    position: absolute;
    left: 19px;
    top: 40px;
    bottom: 0;
    width: 2px;
    background: rgba(99, 102, 241, 0.15);
    display: block;
  }

  .nml__layout-grid .nml__connector {
    display: none;
  }

  /* Text */
  .nml__text {
    padding-top: 0.25rem;
    flex: 1;
  }

  .nml__title {
    font-size: 1.0625rem;
    font-weight: 700;
    color: var(--color-primary);
    margin: 0 0 0.375rem;
    line-height: 1.3;
  }

  .nml__desc {
    font-size: 0.9375rem;
    color: var(--color-muted);
    margin: 0;
    line-height: 1.65;
  }

  .nml__img {
    display: block;
    margin-top: 1rem;
    border-radius: var(--radius);
    max-width: 100%;
    height: auto;
  }

  /* ── LARGE variant ── */
  .nml__variant-large .nml__item {
    padding-bottom: 3.5rem;
  }

  .nml__variant-large .nml__large-wrap {
    position: relative;
    padding-left: 1rem;
  }

  .nml__large-num {
    position: absolute;
    top: -0.5rem;
    left: 0;
    font-size: clamp(4rem, 8vw, 7rem);
    font-weight: 900;
    line-height: 1;
    color: var(--color-accent);
    opacity: 0.12;
    pointer-events: none;
    user-select: none;
    font-variant-numeric: tabular-nums;
    letter-spacing: -0.04em;
  }

  .nml__variant-large .nml__text {
    position: relative;
    padding-top: clamp(2.5rem, 4vw, 4rem);
  }

  .nml__variant-large .nml__title {
    font-size: 1.25rem;
    font-weight: 800;
  }

  /* ── INLINE variant ── */
  .nml__variant-inline .nml__item {
    padding-bottom: 2rem;
  }

  .nml__inline-wrap {
    display: flex;
    flex-direction: column;
    gap: 0.5rem;
  }

  .nml__inline-heading {
    display: flex;
    align-items: center;
    gap: 0.75rem;
    margin: 0;
  }

  .nml__variant-inline .nml__title {
    font-size: 1.125rem;
    font-weight: 700;
    margin: 0;
  }

  .nml__variant-inline .nml__desc {
    padding-left: calc(40px + 0.75rem);
  }

  /* Grid responsive */
  @media (max-width: 768px) {
    .nml__layout-grid .nml__list {
      grid-template-columns: 1fr;
      gap: 1.5rem;
    }
  }

  @media (prefers-reduced-motion: reduce) {
    * { animation: none !important; transition: none !important; }
  }
</style>

Props

Prop Type Default Beschrijving
items * { title: string; description: string; image?: string }[] Lijst items
variant 'default' | 'large' | 'inline' 'default' Nummer weergave stijl
layout 'stack' | 'grid' 'stack' Lay-out richting
eyebrow string Label boven sectie
headline string Sectie headline

* = verplicht