Sticky Sections

Section headers with pushing effect
Demonstrates iOS-style sticky headers using the stickyIndices prop for 20 sections with 10 items each. When a new header scrolls up, it 'pushes' the previous sticky header out of the view.

How to build a feature like this

To build an iOS-contacts-style list whose section headers stick to the top of the scroll container and are pushed out by the next section's header, you mark the header rows with the stickyIndices prop - an array of item indices that should remain sticky. Each marked row scrolls normally until its natural position would pass the container's leading edge, then sticks there while later rows scroll beneath it, and releases when the following sticky row arrives at that edge; that hand-off is what produces the "push". Because the marked rows are ordinary content, you render them through the #item slot and emphasize the pinned state with the isStickyActive flag that slot provides.

1. Flatten the sections into one array and derive header indices

Virtualization addresses a single flat index range, so collapse each section into a header row followed by its body rows. With itemsPerSection body rows per section, every section occupies itemsPerSection + 1 rows, and section s's header sits at flat index s × (itemsPerSection + 1). Mark those header indices in stickyIndices. The natural data shape is a flat array of real objects carrying a type discriminator ('header' | 'item') plus per-row payload, so the #item slot can render either row kind straight from item.

The examples also draw the built-in virtual scrollbar (boolean virtual-scrollbar) on the list. Besides consistent cross-browser styling it is a performance improvement: the overlay bar is driven by the engine's own scroll math, so its rendering cost stays flat no matter how long the list grows.

<script setup lang="ts">
import { VirtualScroll } from '@pdanpdan/virtual-scroll';
import '@pdanpdan/virtual-scroll/style.css';
import { computed, ref } from 'vue';

const sections = ref(20);
const itemsPerSection = ref(10); // body rows per section, after its header
const itemSize = ref(50);

// Each section occupies (itemsPerSection + 1) rows: one header row followed by
// its body rows. Flatten everything into one list, tagging each row's kind.
const items = computed(() => {
  const flat: { type: 'header' | 'item'; label: string }[] = [];
  for (let s = 0; s < sections.value; s++) {
    flat.push({ type: 'header', label: `Section ${ String.fromCharCode(65 + s) }` });
    for (let i = 0; i < itemsPerSection.value; i++) {
      flat.push({ type: 'item', label: `Item ${ s }-${ i }` });
    }
  }
  return flat;
});

// Section s's header lives at flat index s * (itemsPerSection + 1). Those are
// the indices to make sticky: for itemsPerSection = 10 they are 0, 11, 22, …
const stickyIndices = computed(() => {
  const idx: number[] = [];
  for (let i = 0; i < items.value.length; i += itemsPerSection.value + 1) {
    idx.push(i);
  }
  return idx;
});
</script>

2. Pass sticky-indices and render the marked rows distinctly

Bind :sticky-indices="stickyIndices" together with :item-size. A sticky row is a regular row in the list flow - a header is one full item-size-tall row - so keep the slot root filling that row height. In the #item slot branch on item.type to render a header versus a body row. The slot exposes isSticky (this index is configured sticky) and isStickyActive (the row is currently pinned at the edge); use the latter to raise and shade the pinned header. Give the header an opaque background and a higher stacking context: while it is pinned, the following section's rows scroll beneath it, and the next header slides over it to push it out.

<template>
  <VirtualScroll
    virtual-scrollbar
    class="list"
    :items="items"
    :item-size="itemSize"
    :sticky-indices="stickyIndices"
  >
    <!-- isStickyActive is true only while the row is pinned at the edge. Give
         the section header an opaque background so the rows scrolling beneath
         it are covered; when the NEXT header reaches the line it slides over
         and pushes the previous one out (the "iOS pushing" effect). -->
    <template #item="{ item, index, isStickyActive }">
      <div v-if="item.type === 'header'" class="section-header" :class="{ active: isStickyActive }">
        {{ item.label }} <span class="idx">#{{ index }}</span>
      </div>
      <div v-else class="row">{{ item.label }}</div>
    </template>
  </VirtualScroll>
</template>

<style scoped>
.list {
  height: 480px; /* definite viewport: sticky rows pin within THIS container */
}
.row,
.section-header {
  box-sizing: border-box;
  display: flex;
  align-items: center;
  height: 100%; /* fill the 50px item wrapper */
  padding-inline: 1rem;
  border-bottom: 1px solid rgb(0 0 0 / 0.1);
}
.section-header {
  justify-content: space-between;
  background: #fff;
  z-index: 1;
}
.section-header.active {
  box-shadow: 0 2px 6px rgb(0 0 0 / 0.15);
}
.idx {
  color: #888;
  font-size: 0.8em;
}
</style>

3. Mark every section's leading row - headers are ordinary content

stickyIndices accepts any indices, not only section headers: every marked row becomes sticky, and once at the edge it is only released when the next marked row arrives to push it. So give every section one marked leading row (its header) and leave the body rows unmarked - otherwise a header you forgot to mark scrolls away, or a marked row in the middle of a section sticks at the edge mid-section. Two practical points follow from marked rows being in-flow content:

  • Render them at the declared item-size with height: 100% and box-sizing: border-box, so the pinned row matches the geometry the engine uses for the scroll range.
  • Give the pinned row an opaque background and a raised stacking context so the content sliding beneath stays hidden; otherwise the rows you are scrolling "past" remain visible through the header.

Uniform-height sections make the header indices a simple arithmetic stride, as above. If sections vary in height you can still use this feature: keep item-size as a function or switch to dynamic measurement, and mark each section's leading index the same way - only the index derivation changes, not the sticking behavior.

Section A
Item 0-0
Item 0-1
Item 0-2
Item 0-3
  • Scroll Status
  • Direction
    vertical
  • Current Item #
    -
  • Rendered Range #
    0:0
  • Total Size (px)
    0h
  • Viewport Size (px)
    0h
  • Scroll Offset (px)
    0y
  • Controls