Angular Example: Sticky

ts
import {
  ChangeDetectionStrategy,
  Component,
  ElementRef,
  computed,
  viewChild,
} from '@angular/core'
import { faker } from '@faker-js/faker'
import {
  injectVirtualizer,
  defaultRangeExtractor,
} from '@tanstack/angular-virtual'

const groupedNames: Record<string, string[]> = {}

Array.from({ length: 1000 })
  .map(() => faker.person.firstName())
  .sort()
  .forEach((name) => {
    const char = name[0]
    if (!groupedNames[char]) {
      groupedNames[char] = []
    }
    groupedNames[char].push(name)
  })
const groups = Object.keys(groupedNames)
const rows = groups.reduce(
  (acc: string[], k) => [...acc, k, ...groupedNames[k]],
  [],
)
const stickyIndexes = groups.map((gn) => rows.findIndex((n) => n === gn))
const stickyIndexesSet = new Set(stickyIndexes)
const reversedStickyIndexes = [...stickyIndexes].reverse()

@Component({
  selector: 'app-root',
  standalone: true,
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <div #scrollElement class="list scroll-container">
      <div
        style="position: relative; width: 100%;"
        [style.height.px]="virtualizer.getTotalSize()"
      >
        @for (row of virtualizer.getVirtualItems(); track row.index) {
          <div
            [attr.data-index]="row.index"
            style="top: 0; left: 0; width: 100%; background: #fff"
            [style.zIndex]="isSticky(row.index) ? 1 : null"
            [style.borderBottom]="
              isSticky(row.index) ? '1px solid #ddd' : 'none'
            "
            [style.position]="isActiveSticky(row.index) ? 'sticky' : 'absolute'"
            [style.height.px]="row.size"
            [style.transform]="
              isActiveSticky(row.index)
                ? null
                : 'translateY(' + row.start + 'px)'
            "
          >
            {{ rows[row.index] }}
          </div>
        }
      </div>
    </div>
  `,
  styles: `
    .scroll-container {
      height: 300px;
      width: 400px;
      overflow: auto;
    }
  `,
})
export class AppComponent {
  rows = rows

  isSticky = (index: number) => stickyIndexesSet.has(index)

  scrollElement = viewChild<ElementRef<HTMLDivElement>>('scrollElement')

  virtualizer = injectVirtualizer(() => ({
    scrollElement: this.scrollElement(),
    count: this.rows.length,
    estimateSize: () => 50,
    rangeExtractor: (range) => {
      const next = new Set([
        reversedStickyIndexes.find((index) => range.startIndex >= index)!,
        ...defaultRangeExtractor(range),
      ])
      return [...next].sort((a, b) => a - b)
    },
  }))

  activeStickyIndex = computed(() => {
    return this.virtualizer.getVirtualItems()[0]?.index
  })

  isActiveSticky = (index: number) => this.activeStickyIndex() === index
}
import {
  ChangeDetectionStrategy,
  Component,
  ElementRef,
  computed,
  viewChild,
} from '@angular/core'
import { faker } from '@faker-js/faker'
import {
  injectVirtualizer,
  defaultRangeExtractor,
} from '@tanstack/angular-virtual'

const groupedNames: Record<string, string[]> = {}

Array.from({ length: 1000 })
  .map(() => faker.person.firstName())
  .sort()
  .forEach((name) => {
    const char = name[0]
    if (!groupedNames[char]) {
      groupedNames[char] = []
    }
    groupedNames[char].push(name)
  })
const groups = Object.keys(groupedNames)
const rows = groups.reduce(
  (acc: string[], k) => [...acc, k, ...groupedNames[k]],
  [],
)
const stickyIndexes = groups.map((gn) => rows.findIndex((n) => n === gn))
const stickyIndexesSet = new Set(stickyIndexes)
const reversedStickyIndexes = [...stickyIndexes].reverse()

@Component({
  selector: 'app-root',
  standalone: true,
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <div #scrollElement class="list scroll-container">
      <div
        style="position: relative; width: 100%;"
        [style.height.px]="virtualizer.getTotalSize()"
      >
        @for (row of virtualizer.getVirtualItems(); track row.index) {
          <div
            [attr.data-index]="row.index"
            style="top: 0; left: 0; width: 100%; background: #fff"
            [style.zIndex]="isSticky(row.index) ? 1 : null"
            [style.borderBottom]="
              isSticky(row.index) ? '1px solid #ddd' : 'none'
            "
            [style.position]="isActiveSticky(row.index) ? 'sticky' : 'absolute'"
            [style.height.px]="row.size"
            [style.transform]="
              isActiveSticky(row.index)
                ? null
                : 'translateY(' + row.start + 'px)'
            "
          >
            {{ rows[row.index] }}
          </div>
        }
      </div>
    </div>
  `,
  styles: `
    .scroll-container {
      height: 300px;
      width: 400px;
      overflow: auto;
    }
  `,
})
export class AppComponent {
  rows = rows

  isSticky = (index: number) => stickyIndexesSet.has(index)

  scrollElement = viewChild<ElementRef<HTMLDivElement>>('scrollElement')

  virtualizer = injectVirtualizer(() => ({
    scrollElement: this.scrollElement(),
    count: this.rows.length,
    estimateSize: () => 50,
    rangeExtractor: (range) => {
      const next = new Set([
        reversedStickyIndexes.find((index) => range.startIndex >= index)!,
        ...defaultRangeExtractor(range),
      ])
      return [...next].sort((a, b) => a - b)
    },
  }))

  activeStickyIndex = computed(() => {
    return this.virtualizer.getVirtualItems()[0]?.index
  })

  isActiveSticky = (index: number) => this.activeStickyIndex() === index
}
Subscribe to Bytes

Your weekly dose of JavaScript news. Delivered every Monday to over 100,000 devs, for free.

Bytes

No spam. Unsubscribe at any time.

Subscribe to Bytes

Your weekly dose of JavaScript news. Delivered every Monday to over 100,000 devs, for free.

Bytes

No spam. Unsubscribe at any time.

scarf analytics