components: cdk-virtual-scroll-viewport does not handle css position: sticky cleanly

What is the expected behavior?

using the css attribute position: sticky should allow for elements to remain displayed according to their sticky parameters.

What is the current behavior?

This works for a while but then breaks after a point. Im assuming its a matter of the ‘stickied’ dom elements getting recycled.

What are the steps to reproduce?

https://stackblitz.com/edit/angular-wnar6b-wzjdmh

This is a slightly modified version of an example from the docs. As you scroll the cdk-virtual-scroll-viewport you will notice the light blue headers stick to the the top of their area as expected. Once you exceed a certain amount of scrolling however the headers disappear. For me items 0-2 work but the 3rd one breaks.

Down below is an example using the [non-virtual] scrolling. Here the css attribute works as expected.

Which versions of Angular, Material, OS, TypeScript, browsers are affected?

Forked from https://stackblitz.com/angular/apvvljrovad from the docs. no versions changed.

Is there anything else we should know?

https://github.com/angular/material2/issues/11621 also gets at my base requirement; wanting some elements of the virtual scroll list to ‘stick around’. It would be ideal if standard css properties worked as via this ticket but that might be hard as the whole point of virtual-scroll is to remove things from the dom that ‘shouldn’t’ be rendered. The position: sticky css attribute would seem to break those assumptions.

About this issue

  • Original URL
  • State: open
  • Created 5 years ago
  • Reactions: 85
  • Comments: 53 (9 by maintainers)

Most upvoted comments

My use case is that I need a sticky header always present above a virtually-scrolling list ; I’m probably not the only one. Would you guys accept a PR that allows such a behavior (namely, a sticky header)?

any luck? looking for same solution.

sticky: true" is working fine with mat table if we don’t use virtual scrolling, but it is messed up with virtual scrolling. Header starts moving when you start scrolling.

@florenthobein I found a solution to the tranformY compensation, but for some reason it’s not sufficient, as once I started scrolling too far down, the headers will scroll with the content. Regardless, this is what I came up with after fiddling around a bit with the CDK scroll view port.

cdk-virtual-scroll-viewport {
    height: 100%;

    table {
        height: 100%;
        width: 100%;
        border-collapse: separate;
        border-spacing: 0;

        thead {
            tr {
                th {
                    height: 59px;
                    border-bottom: 1px solid #d0d0d0;
                    padding: 7px 20px 7px 7px;
                    position: sticky;
                    background-color: #ffffff;
                    top: 0;
            }
        }
    }
}
<cdk-virtual-scroll-viewport #scrollViewport itemSize="59" (scrolledIndexChange)="scrollIndexChanged($event)">
  <table>
    <thead>
      <tr>
        <th [style.transform]="inverseTranslation">Header 1</th>
        <th [style.transform]="inverseTranslation">Header 2</th>
      </tr>
    </thead>
    <tbody>
      <tr *cdkVirtualFor="let movement of movements; even as even; index as i" [class.even]="even">...</tr>
    </tbody>
  </table>
</cdk-virtual-scroll-viewport>
@ViewChild(CdkVirtualScrollViewport)
public viewPort: CdkVirtualScrollViewport;

public get inverseTranslation(): string {
    if (!this.viewPort || !this.viewPort["_renderedContentTransform"]) {
        return "translateY(0px)";
    }
    return this.viewPort["_renderedContentTransform"].replace(/translateY\((\d+)px\)/, "translateY(-$1px)");
}

That gets rid of the weird effects, but only until you scrolled down for a while. Not sure what I’m doing wrong and if that’s even a problem I could solve. I’m not too familiar with sticky positions, it’s still sometimes a mystery how that works exactly.

The problem with the solution you posted is that you’re using style.transform. A position: sticky evaluates to a position: fixed once you hit the part where it would scroll, and that doesn’t work right with a transformation.

Instead use style.top and set it to the inverse number of pixels, similar to what you’re doing now.

Just following up on this, any update. This is must requirement for table with large dataset.

More than 4 years have passed since the opening of the issue, and the solution has not appeared. It is sad.

Hi all, has anyone found a solution for this without any header flicker?

Working example with dynamic sticky items with correct placement: https://stackblitz.com/edit/angular-ivy-pxmnpx?file=src/app/app.component.html (css vars need angular 10+) An issue can arise where the sticky element will jump 1 px up and down if the rows get sub-pixel-height (in my project bc. the rows are calculated from viewport / 4). This can be avoided by making sure that the viewport height is dividable by e.g. 4 in my case (see https://stackoverflow.com/questions/37754542/css-calc-round-down-with-two-decimal-cases/64921523)

Remember to implement something like:

if (platform.SAFARI) {
this.offset = "0px";
}

@artem-galas https://stackblitz.com/edit/components-issue-t3xvyz That works for my use case.

that was a good example and it actually keeps the header in top, but in my case I have resizable columns and header position fails when moving 😦 look here https://gifyu.com/image/kyj4

@mmalerba Can you get this on the virtual scrolling bug list https://github.com/angular/components/projects/20

i added this to my header row like so

    <tr
      mat-header-row
      *matHeaderRowDef="displayedColumns; sticky: true"
    ></tr>

Once #24394 is released (I guess 14.1), it is possible to have sticky elements before the scroll-viewport but within the scrolling element (adopted from the dev-app):

<div class="demo-viewport" cdkVirtualScrollingElement>
  <p class="position: sticky; top: 0">Content before virtual scrolling items</p>
  <cdk-virtual-scroll-viewport itemSize="50">
    <div *cdkVirtualFor="let size of fixedSizeData; let i = index" class="demo-item"
         [style.height.px]="size">
      Item #{{i}} - ({{size}}px)
    </div>
  </cdk-virtual-scroll-viewport>
  <p>Content after virtual scrolling items</p>
</div>

As you have mentioned, this only works for non-table elements. Since the th element in the thead needs to be sticky, the cdk-virtual-scroll-viewport must be placed inside a table which does not work:

<div cdkVirtualScrollingElement>
  <table>
    <thead>
      <tr>
        <th style="position: sticky; top: 0;">Header</th>
      </tr>
    </thead>
    <tbody>
      <cdk-virtual-scroll-viewport itemSize="50">
        <tr *cdkVirtualFor="let row of rows" style="height: 50px">
          <td>Row data</td>
        </tr>
      </cdk-virtual-scroll-viewport>
    </tbody>
  </table>
</div>

Have you ever considered making this a directive instead? Most of its logic could be the same, but instead of manipulating the DOM directly, the directive would provide (in addition to the other values) two “spacing” values. One that represents an offset from the top, and another one from the bottom.

Using these values, the user could offset the elements appropriately. Take a look at a non-Angular example. This solution only refreshes the table on scroll finish event, however, the idea is the same. When the viewport items needs to be refreshed, the first and last “imaginary” row height is updated accordingly.

Once #24394 is released (I guess 14.1), it is possible to have sticky elements before the scroll-viewport but within the scrolling element (adopted from the dev-app):

<div class="demo-viewport" cdkVirtualScrollingElement>
  <p class="position: sticky; top: 0">Content before virtual scrolling items</p>
  <cdk-virtual-scroll-viewport itemSize="50">
    <div *cdkVirtualFor="let size of fixedSizeData; let i = index" class="demo-item"
         [style.height.px]="size">
      Item #{{i}} - ({{size}}px)
    </div>
  </cdk-virtual-scroll-viewport>
  <p>Content after virtual scrolling items</p>
</div>

So with this it should be possible to implement sticky headers.

However, using position:; sticky on items within the scroll-viewport is still not working. I guess this cannot be simply fixed within the cdk, but at least it could provide developers a better way to deal with it. As described above, the translate value needs to be compensated in top: ?.

@mmalerba what do you think about this approach: Exposing the current translate value as a css variable. Doing this would allow developers to make elements sticky like this, without the need of any additional js:

.sticky-element {
  position: sticky;
  top: calc(<wanted-top-value> + var(--cdk-scrolling-offset))
}

@florenthobein I found a solution to the tranformY compensation, but for some reason it’s not sufficient, as once I started scrolling too far down, the headers will scroll with the content. Regardless, this is what I came up with after fiddling around a bit with the CDK scroll view port.

cdk-virtual-scroll-viewport {
    height: 100%;

    table {
        height: 100%;
        width: 100%;
        border-collapse: separate;
        border-spacing: 0;

        thead {
            tr {
                th {
                    height: 59px;
                    border-bottom: 1px solid #d0d0d0;
                    padding: 7px 20px 7px 7px;
                    position: sticky;
                    background-color: #ffffff;
                    top: 0;
            }
        }
    }
}
<cdk-virtual-scroll-viewport #scrollViewport itemSize="59" (scrolledIndexChange)="scrollIndexChanged($event)">
  <table>
    <thead>
      <tr>
        <th [style.transform]="inverseTranslation">Header 1</th>
        <th [style.transform]="inverseTranslation">Header 2</th>
      </tr>
    </thead>
    <tbody>
      <tr *cdkVirtualFor="let movement of movements; even as even; index as i" [class.even]="even">...</tr>
    </tbody>
  </table>
</cdk-virtual-scroll-viewport>
@ViewChild(CdkVirtualScrollViewport)
public viewPort: CdkVirtualScrollViewport;

public get inverseTranslation(): string {
    if (!this.viewPort || !this.viewPort["_renderedContentTransform"]) {
        return "translateY(0px)";
    }
    return this.viewPort["_renderedContentTransform"].replace(/translateY\((\d+)px\)/, "translateY(-$1px)");
}

That gets rid of the weird effects, but only until you scrolled down for a while. Not sure what I’m doing wrong and if that’s even a problem I could solve. I’m not too familiar with sticky positions, it’s still sometimes a mystery how that works exactly.

inverseTranslation function can be improved as follows:

  get inverseTranslation(): string {
    const offset = this.viewPort.getOffsetToRenderedContentStart();

    return `-${offset}px`;
  }

or

get inverseTranslation(): string {
    return `-${this.viewPort.getOffsetToRenderedContentStart()}px`;
  }

@florenthobein I found a solution to the tranformY compensation, but for some reason it’s not sufficient, as once I started scrolling too far down, the headers will scroll with the content. Regardless, this is what I came up with after fiddling around a bit with the CDK scroll view port.

cdk-virtual-scroll-viewport {
    height: 100%;

    table {
        height: 100%;
        width: 100%;
        border-collapse: separate;
        border-spacing: 0;

        thead {
            tr {
                th {
                    height: 59px;
                    border-bottom: 1px solid #d0d0d0;
                    padding: 7px 20px 7px 7px;
                    position: sticky;
                    background-color: #ffffff;
                    top: 0;
            }
        }
    }
}
<cdk-virtual-scroll-viewport #scrollViewport itemSize="59" (scrolledIndexChange)="scrollIndexChanged($event)">
  <table>
    <thead>
      <tr>
        <th [style.transform]="inverseTranslation">Header 1</th>
        <th [style.transform]="inverseTranslation">Header 2</th>
      </tr>
    </thead>
    <tbody>
      <tr *cdkVirtualFor="let movement of movements; even as even; index as i" [class.even]="even">...</tr>
    </tbody>
  </table>
</cdk-virtual-scroll-viewport>
@ViewChild(CdkVirtualScrollViewport)
public viewPort: CdkVirtualScrollViewport;

public get inverseTranslation(): string {
    if (!this.viewPort || !this.viewPort["_renderedContentTransform"]) {
        return "translateY(0px)";
    }
    return this.viewPort["_renderedContentTransform"].replace(/translateY\((\d+)px\)/, "translateY(-$1px)");
}

That gets rid of the weird effects, but only until you scrolled down for a while. Not sure what I’m doing wrong and if that’s even a problem I could solve. I’m not too familiar with sticky positions, it’s still sometimes a mystery how that works exactly.

@artem-galas https://stackblitz.com/edit/components-issue-t3xvyz That works for my use case.

based on that, i found working clean solution. inverseOfTranslation (viewportOffsetNumber below) should be equal to the next (-1) * offset number, while scrolling. how to achieve that?

  <cdk-virtual-scroll-viewport [itemSize]=[virtualItemSize]>
    <table>
      <thead>
        <tr>
          <th [style.top]="viewportOffsetNumber">Column 1</th>
          <th [style.top]="viewportOffsetNumber">Column 2</th>
        </tr>
      </thead>
      <tbody>
        <tr *cdkVirtualFor="let row of rows">
          <td>{{row.col1}}</td>
          <td>{{row.col2}}</td>
        </tr>
      </tbody>
    </table>
  </cdk-virtual-scroll-viewport>
</div>
ngAfterViewInit(): void {
  this.viewport.renderedRangeStream.pipe(
    tap(range => {
      this.viewportOffsetNumber = `-${this.virtualItemSize * range.start}px`;
    })
  ).subscribe();
}

I managed to make it work. Not perfect, but it works. Inside the ngOnInit put the next piece of code

this.viewport.scrolledIndexChange.subscribe(() => {

  const el = this.viewport.elementRef.nativeElement.getElementsByClassName('cdk-virtual-scroll-content-wrapper') as HTMLCollectionOf<HTMLElement>;

  if (el.length > 0 && el[0].style.transform) {
    const headerCells = this.viewport.elementRef.nativeElement.getElementsByClassName('mat-header-cell') as HTMLCollectionOf<HTMLElement>;
    const translateY = el[0].style.transform.replace('translateY(', '').slice(0, -1);

    for (let i = 0; i < headerCells.length; i++) {
      if (headerCells[i].style.transform !== ('translateY(-' + translateY + ')')) {
        headerCells[i].style.transform = 'translateY(-' + translateY + ')';
      }
    }
  }

})

Once #24394 is released (I guess 14.1), it is possible to have sticky elements before the scroll-viewport but within the scrolling element (adopted from the dev-app):

<div class="demo-viewport" cdkVirtualScrollingElement>
  <p class="position: sticky; top: 0">Content before virtual scrolling items</p>
  <cdk-virtual-scroll-viewport itemSize="50">
    <div *cdkVirtualFor="let size of fixedSizeData; let i = index" class="demo-item"
         [style.height.px]="size">
      Item #{{i}} - ({{size}}px)
    </div>
  </cdk-virtual-scroll-viewport>
  <p>Content after virtual scrolling items</p>
</div>

As you have mentioned, this only works for non-table elements. Since the th element in the thead needs to be sticky, the cdk-virtual-scroll-viewport must be placed inside a table which does not work:

<div cdkVirtualScrollingElement>
  <table>
    <thead>
      <tr>
        <th style="position: sticky; top: 0;">Header</th>
      </tr>
    </thead>
    <tbody>
      <cdk-virtual-scroll-viewport itemSize="50">
        <tr *cdkVirtualFor="let row of rows" style="height: 50px">
          <td>Row data</td>
        </tr>
      </cdk-virtual-scroll-viewport>
    </tbody>
  </table>
</div>

Have you ever considered making this a directive instead? Most of its logic could be the same, but instead of manipulating the DOM directly, the directive would provide (in addition to the other values) two “spacing” values. One that represents an offset from the top, and another one from the bottom.

Using these values, the user could offset the elements appropriately. Take a look at a non-Angular example. This solution only refreshes the table on scroll finish event, however, the idea is the same. When the viewport items needs to be refreshed, the first and last “imaginary” row height is updated accordingly.

Hey, is there any news on this? Are there plans to allow sticky elements to work inside a table?

@florenthobein I found a solution to the tranformY compensation, but for some reason it’s not sufficient, as once I started scrolling too far down, the headers will scroll with the content. Regardless, this is what I came up with after fiddling around a bit with the CDK scroll view port.

cdk-virtual-scroll-viewport {
    height: 100%;

    table {
        height: 100%;
        width: 100%;
        border-collapse: separate;
        border-spacing: 0;

        thead {
            tr {
                th {
                    height: 59px;
                    border-bottom: 1px solid #d0d0d0;
                    padding: 7px 20px 7px 7px;
                    position: sticky;
                    background-color: #ffffff;
                    top: 0;
            }
        }
    }
}
<cdk-virtual-scroll-viewport #scrollViewport itemSize="59" (scrolledIndexChange)="scrollIndexChanged($event)">
  <table>
    <thead>
      <tr>
        <th [style.transform]="inverseTranslation">Header 1</th>
        <th [style.transform]="inverseTranslation">Header 2</th>
      </tr>
    </thead>
    <tbody>
      <tr *cdkVirtualFor="let movement of movements; even as even; index as i" [class.even]="even">...</tr>
    </tbody>
  </table>
</cdk-virtual-scroll-viewport>
@ViewChild(CdkVirtualScrollViewport)
public viewPort: CdkVirtualScrollViewport;

public get inverseTranslation(): string {
    if (!this.viewPort || !this.viewPort["_renderedContentTransform"]) {
        return "translateY(0px)";
    }
    return this.viewPort["_renderedContentTransform"].replace(/translateY\((\d+)px\)/, "translateY(-$1px)");
}

That gets rid of the weird effects, but only until you scrolled down for a while. Not sure what I’m doing wrong and if that’s even a problem I could solve. I’m not too familiar with sticky positions, it’s still sometimes a mystery how that works exactly.

The problem with the solution you posted is that you’re using style.transform. A position: sticky evaluates to a position: fixed once you hit the part where it would scroll, and that doesn’t work right with a transformation.

Instead use style.top and set it to the inverse number of pixels, similar to what you’re doing now.

The inverseTranslation method will be call on every view check, maybe it is not ideal regarding performances.

The CdkVirtualScrollViewport has a ScrollStrategy private property offering a onRenderedOffsetChanged method we can use to update the header position:

       <tr>
        <th [style.top]="headerTop">Header 1</th>
        <th [style.top]="headerTop">Header 2</th>
      </tr>
    headerTop = '0px';

    ngAfterViewInit(): void {
        if (!!this.viewPort) {
            this.viewPort['_scrollStrategy'].onRenderedOffsetChanged = () =>
                (this.headerTop = `-${this.viewPort.getOffsetToRenderedContentStart()}px`);
        }
    }

FWIW, I noticed that it does work as expected in Safari if you use position: -webkit-sticky;. Not sure if Webkit is the only one doing it right, or if they’re just doing it in a different way that happens to work better for this case.

Here too, same problem now. @florenthobein which kind of workaround have you come up with? What’s the root cause of this behavior?

Edit: okay, it seems transformY is the cause.