diff --git a/docs/releases/v2.10.2.md b/docs/releases/v2.10.2.md
new file mode 100644
index 0000000000000000000000000000000000000000..2e0980ac593862b09b16019bea5d61518286adb6
--- /dev/null
+++ b/docs/releases/v2.10.2.md
@@ -0,0 +1,7 @@
+# v2.10.2
+
+## Bugfix
+
+- paginate feature requests to hopefully improve server performance
+- bump siibra-api version
+- fix ng layer control style pollution
diff --git a/mkdocs.yml b/mkdocs.yml
index 4bc43ba5039d6018e642df3e329279aba41773c0..0049347033a0f60336c29db577942fecd6ebacfd 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -33,6 +33,7 @@ nav:
     - Fetching datasets: 'advanced/datasets.md'
     - Display non-atlas volumes: 'advanced/otherVolumes.md'
   - Release notes:
+    - v2.10.2: 'releases/v2.10.2.md'
     - v2.10.1: 'releases/v2.10.1.md'
     - v2.10.0: 'releases/v2.10.0.md'
     - v2.9.1: 'releases/v2.9.1.md'
diff --git a/package.json b/package.json
index 2ee728b99bcd28332fe61fd2bd853037bae814ac..34add91c280786f89455ea5df1c314d5fe263df3 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
 {
   "name": "siibra-explorer",
-  "version": "2.10.1",
+  "version": "2.10.2",
   "description": "siibra-explorer - explore brain atlases. Based on humanbrainproject/nehuba & google/neuroglancer. Built with angular",
   "scripts": {
     "lint": "eslint src --ext .ts",
diff --git a/src/atlasComponents/sapi/sapi.service.ts b/src/atlasComponents/sapi/sapi.service.ts
index 65a12c19b1c460020327b4fbb6346c933e240294..c5ac1ecd6501d24d6d731ee5077d06b7f9d95b4d 100644
--- a/src/atlasComponents/sapi/sapi.service.ts
+++ b/src/atlasComponents/sapi/sapi.service.ts
@@ -22,7 +22,7 @@ export const useViewer = {
 } as const
 
 export const SIIBRA_API_VERSION_HEADER_KEY='x-siibra-api-version'
-export const EXPECTED_SIIBRA_API_VERSION = '0.3.1'
+export const EXPECTED_SIIBRA_API_VERSION = '0.3.2'
 
 let BS_ENDPOINT_CACHED_VALUE: Observable<string> = null
 
diff --git a/src/atlasComponents/sapi/translateV3.ts b/src/atlasComponents/sapi/translateV3.ts
index b5dcc4a019f4e9f229d022e626f07e25b7728170..79775ba84f4ca64aa8a03169373ed1049a487c92 100644
--- a/src/atlasComponents/sapi/translateV3.ts
+++ b/src/atlasComponents/sapi/translateV3.ts
@@ -419,7 +419,7 @@ class TranslateV3 {
     }
   }
 
-  async translateFeature(feat: PathReturn<"/feature/{feature_id}">): Promise<TabularFeature<number|string|number[]>|Feature> {
+  async translateFeature(feat: PathReturn<"/feature/{feature_id}">): Promise<TabularFeature<number|string|number[]>|VoiFeature|Feature> {
     if (this.#isTabular(feat)) {
       return await this.translateTabularFeature(feat)
     }
diff --git a/src/features/category-acc.directive.ts b/src/features/category-acc.directive.ts
index 1ddb52d0c90c956c89f3ab62a0b6ee1f5518d586..ca2b5e818862b99fab0e3f8f8a513c75356b34f0 100644
--- a/src/features/category-acc.directive.ts
+++ b/src/features/category-acc.directive.ts
@@ -1,13 +1,14 @@
-import { AfterContentInit, ContentChildren, Directive, OnDestroy, QueryList } from '@angular/core';
-import { BehaviorSubject, combineLatest, Subscription } from 'rxjs';
-import { map } from 'rxjs/operators';
-import { Feature } from "src/atlasComponents/sapi/sxplrTypes"
-import { ListDirective } from './list/list.directive';
+import { AfterContentInit, ContentChildren, Directive, Input, OnDestroy, QueryList } from '@angular/core';
+import { combineLatest, merge, of, Subject, Subscription } from 'rxjs';
+import { distinctUntilChanged, map, scan, shareReplay, switchMap } from 'rxjs/operators';
+import { ListDirective, TranslatedFeature } from './list/list.directive';
+import { ParentDatasource, PulledDataSource } from 'src/util/pullable';
+import { arrayEqual } from 'src/util/array';
 
-export type GroupedFeature = {
-  features: Feature[]
+export type FeatureMetadata = {
   meta: {
     displayName: string
+    total: number
   }
 }
 
@@ -16,57 +17,118 @@ export type GroupedFeature = {
   exportAs: 'categoryAcc'
 })
 export class CategoryAccDirective implements AfterContentInit, OnDestroy {
+  
+  #listCmps$ = new Subject<ListDirective[]>()
+  
+  public isBusy$ = this.#listCmps$.pipe(
+    switchMap(cmps =>
+      combineLatest(
+        cmps.map(
+          cmp => cmp.isBusy$
+        )
+      ).pipe(
+        map(isBusyState => isBusyState.some(state => state))
+      )
+    )
+  )
+  public total$ = this.#listCmps$.pipe(
+    switchMap(listCmps =>
+      combineLatest(
+        listCmps.map(cmp => cmp.total$)
+      ).pipe(
+        map(totals => totals.reduce((acc, curr) => acc + curr)),
+      )
+    ),
+    shareReplay(1)
+  )
 
-  public isBusy$ = new BehaviorSubject<boolean>(false)
-  public total$ = new BehaviorSubject<number>(0)
-  public groupedFeatures$ = new BehaviorSubject<GroupedFeature[]>([])
-  public features$ = this.groupedFeatures$.pipe(
-    map(arr => arr.flatMap(val => val.features))
+  public featureMetadata$ = this.#listCmps$.pipe(
+    switchMap(listcmps => {
+      if (listcmps.length === 0) {
+        return of([] as FeatureMetadata[])
+      }
+      return merge(
+        ...listcmps.map(cmp =>
+          cmp.total$.pipe(
+            map(total => ({
+              [cmp.displayName]: total
+            }))
+          )
+        )
+      ).pipe(
+        scan((acc, curr) => {
+          return {
+            ...acc,
+            ...curr
+          }
+        }, {} as Record<string, number>),
+        map(record => {
+          const returnVal: FeatureMetadata[] = []
+          for (const key in record) {
+            returnVal.push({
+              meta: {
+                displayName: key,
+                total: record[key]
+              }
+            })
+          }
+          return returnVal
+        })
+      )
+    })
   )
 
   @ContentChildren(ListDirective, { read: ListDirective, descendants: true })
   listCmps: QueryList<ListDirective>
 
-  #changeSub: Subscription
-  ngAfterContentInit(): void {
-    this.#registerListCmps()
-    this.#changeSub = this.listCmps.changes.subscribe(() => this.#registerListCmps())
-  }
-
-  ngOnDestroy(): void {
-    this.#cleanup()
-  }
+  #unchecked$ = new Subject<FeatureMetadata[]>()
+  unchecked$ = this.#unchecked$.pipe(
+    distinctUntilChanged(
+      arrayEqual((o, n) => o.meta.displayName === n.meta.displayName && o.meta.total === n.meta.total)
+    )
+  )
 
-  #subscriptions: Subscription[] = []
-  #cleanup(){
-    if (this.#changeSub) this.#changeSub.unsubscribe()
-    while(this.#subscriptions.length > 0) this.#subscriptions.pop().unsubscribe()
+  @Input()
+  set unchecked(val: FeatureMetadata[]){
+    this.#unchecked$.next(val)
   }
-  #registerListCmps(){
-    this.#cleanup()
 
-    const listCmp = Array.from(this.listCmps)
-
-    this.#subscriptions.push(
-      combineLatest(
-        listCmp.map(
-          listC => listC.features$.pipe(
-            map(features => ({ features, meta: { displayName: listC.displayName } }))
-          )
-        )
-      ).subscribe(val => this.groupedFeatures$.next(val)),
+  public datasource$ = combineLatest([
+    this.unchecked$,
+    this.#listCmps$,
+  ]).pipe(
+    switchMap(([ unchecked, listCmps ]) => {
+      const hideFeatureNames = unchecked.map(c => c.meta.displayName)
+      const filteredListCmps = listCmps.filter(cmp => !hideFeatureNames.includes(cmp.displayName))
       
-      combineLatest(
-        listCmp.map(listC => listC.features$)
+      if (filteredListCmps.length === 0) {
+        return of(
+          new ParentDatasource({
+            children: [] as PulledDataSource<TranslatedFeature>[]
+          })
+        )
+      }
+      return combineLatest(
+        filteredListCmps.map(cmp => cmp.datasource$)
       ).pipe(
-        map(features => features.reduce((acc, curr) => acc + curr.length, 0))
-      ).subscribe(total => this.total$.next(total)),
+        map(dss => new ParentDatasource({ children: dss })),
+      )
+    })
+  )
 
-      combineLatest(
-        listCmp.map(listC => listC.state$)
-      ).pipe(
-        map(states => states.some(state => state === "busy"))
-      ).subscribe(flag => this.isBusy$.next(flag))
+  #changeSub: Subscription
+  ngAfterContentInit(): void {
+    this.#changeSub = this.listCmps.changes.subscribe(() => {
+      this.#listCmps$.next(
+        Array.from(this.listCmps)
+      )
+    })
+    this.#listCmps$.next(
+      Array.from(this.listCmps)
     )
   }
+
+  ngOnDestroy(): void {
+    if (this.#changeSub) this.#changeSub.unsubscribe() 
+  }
 }
diff --git a/src/features/entry/entry.component.ts b/src/features/entry/entry.component.ts
index 52ab5d22e8fe6ba057f3adadd56a7d7887686d7d..abe5920301de67270962a927372ba8efd4a573fe 100644
--- a/src/features/entry/entry.component.ts
+++ b/src/features/entry/entry.component.ts
@@ -8,6 +8,7 @@ import * as userInteraction from "src/state/userInteraction"
 import { atlasSelection } from 'src/state';
 import { CategoryAccDirective } from "../category-acc.directive"
 import { BehaviorSubject, combineLatest, merge, of, Subscription } from 'rxjs';
+import { IsAlreadyPulling, PulledDataSource } from 'src/util/pullable';
 
 const categoryAcc = <T extends Record<string, unknown>>(categories: T[]) => {
   const returnVal: Record<string, T[]> = {}
@@ -36,7 +37,6 @@ export class EntryComponent extends FeatureBase implements AfterViewInit, OnDest
 
   public busyTallying$ = new BehaviorSubject<boolean>(false)
   public totals$ = new BehaviorSubject<number>(null)
-  public features$ = new BehaviorSubject<Feature[]>([])
 
   constructor(private sapi: SAPI, private store: Store) {
     super()
@@ -77,13 +77,6 @@ export class EntryComponent extends FeatureBase implements AfterViewInit, OnDest
           this.totals$.next(num)
         }),
       ).subscribe(),
-
-      catAccDirs$.pipe(
-        switchMap(catArrDirs => combineLatest(
-          catArrDirs.map(dir => dir.features$)
-        )),
-        map(features => features.flatMap(f => f))
-      ).subscribe(features => this.features$.next(features))
     )
   }
 
@@ -140,5 +133,17 @@ export class EntryComponent extends FeatureBase implements AfterViewInit, OnDest
       })
     )
   }
-}
 
+  async onScroll(datasource: PulledDataSource<unknown>, scrollIndex: number){
+    if ((datasource.currentValue.length - scrollIndex) < 30) {
+      try {
+        await datasource.pull()
+      } catch (e) {
+        if (e instanceof IsAlreadyPulling) {
+          return
+        }
+        throw e
+      }
+    }
+  }
+}
diff --git a/src/features/entry/entry.flattened.component.html b/src/features/entry/entry.flattened.component.html
index 92c930de8f5c9ade894d8f5054baffabd93511d8..900633de87ef78a98e780355fa0fa816fdaef9e1 100644
--- a/src/features/entry/entry.flattened.component.html
+++ b/src/features/entry/entry.flattened.component.html
@@ -51,6 +51,7 @@
 <ng-template #featureCategoryFeatureTmpl let-keyvalue>
     <mat-expansion-panel
         sxplrCategoryAcc
+        [unchecked]="filterFeatureCls.unchecked$ | async"
         #categoryAcc="categoryAcc"
         [ngClass]="{
             'sxplr-d-none': !(categoryAcc.isBusy$ | async) && (categoryAcc.total$ | async) === 0
@@ -60,12 +61,12 @@
                 {{ keyvalue.key }}
             </mat-panel-title>
             <mat-panel-description>
-                <spinner-cmp *ngIf="categoryAcc.isBusy$ | async"></spinner-cmp>
                 <ng-template [ngIf]="categoryAcc.total$ | async" let-total>
                     <span>
                         {{ total }}
                     </span>
                 </ng-template>
+                <spinner-cmp class="sxplr-ml-2 sxplr-d-block" *ngIf="categoryAcc.isBusy$ | async"></spinner-cmp>
             </mat-panel-description>
         </mat-expansion-panel-header>
 
@@ -74,7 +75,7 @@
         <div class="mat-chip-container"
             feature-filter-directive
             [initValue]="true"
-            [items]="categoryAcc.groupedFeatures$ | async "
+            [items]="categoryAcc.featureMetadata$ | async "
             #filterFeatureCls="featureFilterDirective">
 
             <div class="mat-chip-inner-container">
@@ -85,7 +86,7 @@
                 </button>
 
                 <ng-template ngFor [ngForOf]="filterFeatureCls.items" let-grpFeat>
-                    <ng-template [ngIf]="grpFeat.features.length > 0">
+                    <ng-template [ngIf]="grpFeat.meta.total > 0">
 
                         <ng-template [ngIf]="filterFeatureCls.checked$ | async | grpFeatToName | includes : grpFeat.meta.displayName"
                             [ngIfThen]="selectedTmpl"
@@ -97,7 +98,7 @@
                                 {{ grpFeat.meta.displayName }}
                             </span>
                             <span class="text-muted1">
-                                ({{ grpFeat.features.length }})
+                                ({{ grpFeat.meta.total }})
                             </span>
                         </ng-template>
                         <ng-template #selectedTmpl>
@@ -127,6 +128,7 @@
 
         <ng-template ngFor [ngForOf]="keyvalue.value" let-feature>
 
+            <!-- collected by CategoryAccDirective with ContentChildren -->
             <div sxplr-feature-list-directive
                 [template]="template"
                 [parcellation]="parcellation"
@@ -135,26 +137,43 @@
                 [queryParams]="queryParams | mergeObj : { type: (feature.name | featureNamePipe) }"
                 [featureRoute]="feature.path"
                 [name]="feature.name"
-                [displayName]="feature.display_name"
-                #featureListDirective="featureListDirective">
+                [displayName]="feature.display_name">
             </div>
         </ng-template>
+
+        <ng-template [ngIf]="categoryAcc.datasource$ | async" let-datasource>
+            <cdk-virtual-scroll-viewport itemSize="36"
+                (scrolledIndexChange)="onScroll(datasource, $event)"
+                class="virtual-scroll-viewport">
+
+                <ng-template cdkVirtualFor
+                    [cdkVirtualForOf]="datasource"
+                    let-feature
+                    let-last="last">
+
+                    <button
+                        mat-button
+                        class="virtual-scroll-item sxplr-w-100"
+                        [matTooltip]="feature.name"
+                        matTooltipPosition="right"
+                        (click)="onClickFeature(feature)">
+                        {{ feature.name }}
+                    </button>
+
+                    <div *ngIf="last && datasource.isPulling$ | async">
+                        <ng-template [ngTemplateOutlet]="loadingSpinnerTmpl">
+                        </ng-template>
+                    </div>
+
+
+                </ng-template>
+            </cdk-virtual-scroll-viewport>
+        </ng-template>
         
-        <cdk-virtual-scroll-viewport itemSize="36"
-            filter-grp-feat
-            [featureDisplayName]="filterFeatureCls.checked$ | async | mapToProperty : 'meta' | mapToProperty : 'displayName'"
-            [groupFeature]="categoryAcc.groupedFeatures$ | async"
-            #filterGrpFeat="filterGrpFeat"
-            class="virtual-scroll-viewport">
-
-            <button *cdkVirtualFor="let feature of filterGrpFeat.filteredFeatures$"
-                mat-button
-                class="virtual-scroll-item sxplr-w-100"
-                [matTooltip]="feature.name"
-                matTooltipPosition="right"
-                (click)="onClickFeature(feature)">
-                {{ feature.name }}
-            </button>
-        </cdk-virtual-scroll-viewport>
     </mat-expansion-panel>
-</ng-template>
\ No newline at end of file
+</ng-template>
+
+
+<ng-template #loadingSpinnerTmpl>
+    <spinner-cmp class="sxplr-pl-2 sxplr-d-block"></spinner-cmp>
+</ng-template>
diff --git a/src/features/feature.filter.directive.ts b/src/features/feature.filter.directive.ts
index 14421065f2a2ed090a8c31dfef2c41877ddf3152..e5d6bceafa6811101348451e44c7ccba76f22a9e 100644
--- a/src/features/feature.filter.directive.ts
+++ b/src/features/feature.filter.directive.ts
@@ -32,7 +32,7 @@ export class FeatureFilterDirective<T> implements OnChanges{
     this.#initValue$
   ]).pipe(
     switchMap(([items, initFlag]) => {
-      const initialCondition = items.map(item => ({ item, flag: initFlag }))
+      const initialCondition = (items || []).map(item => ({ item, flag: initFlag }))
       return merge<{ target: T, flag?: boolean, op: string }>(
         this.#toggle$.pipe(
           map(v => ({ ...v, op: 'toggle' }))
diff --git a/src/features/filterGrpFeat.directive.ts b/src/features/filterGrpFeat.directive.ts
deleted file mode 100644
index 3caa9416d934aa64a43716dbad8108ee697e698f..0000000000000000000000000000000000000000
--- a/src/features/filterGrpFeat.directive.ts
+++ /dev/null
@@ -1,31 +0,0 @@
-import { Directive, Input, OnChanges, SimpleChanges } from "@angular/core";
-import { GroupedFeature } from "./category-acc.directive";
-import { combineLatest, Subject } from "rxjs";
-import { map } from "rxjs/operators";
-
-@Directive({
-  selector: '[filter-grp-feat]',
-  exportAs: 'filterGrpFeat'
-})
-export class FilterGroupList implements OnChanges{
-
-  @Input()
-  featureDisplayName: string[] = []
-  #featureDisplayName = new Subject<string[]>()
-
-  @Input()
-  groupFeature: GroupedFeature[] = []
-  #groupFeature = new Subject<GroupedFeature[]>()
-
-  filteredFeatures$ = combineLatest([
-    this.#featureDisplayName,
-    this.#groupFeature
-  ]).pipe(
-    map(([ displaynames, grpfeats ]) => grpfeats.filter(feat => displaynames.includes(feat.meta.displayName)).flatMap(f => f.features))
-  )
-
-  ngOnChanges(): void {
-    this.#featureDisplayName.next(this.featureDisplayName)
-    this.#groupFeature.next(this.groupFeature)
-  }
-}
diff --git a/src/features/grpFeatToName.pipe.ts b/src/features/grpFeatToName.pipe.ts
index f124a683609247864bb79b1a55d9055c7b16627d..050f6ec2c3e074d19dda50b9e2c649c19c0e3bca 100644
--- a/src/features/grpFeatToName.pipe.ts
+++ b/src/features/grpFeatToName.pipe.ts
@@ -1,5 +1,10 @@
 import { Pipe, PipeTransform } from "@angular/core";
-import { GroupedFeature } from "./category-acc.directive";
+
+interface MetaDisplayName {
+  meta: {
+    displayName: string
+  }
+}
 
 @Pipe({
   name: 'grpFeatToName',
@@ -7,7 +12,7 @@ import { GroupedFeature } from "./category-acc.directive";
 })
 
 export class GroupFeaturesToName implements PipeTransform{
-  public transform(groupFeats: GroupedFeature[]): string[] {
+  public transform(groupFeats: MetaDisplayName[]): string[] {
     return groupFeats.map(f => f.meta.displayName)
   }
 }
diff --git a/src/features/list/list.component.html b/src/features/list/list.component.html
index 28d0b7dabbee811aecda8eda61e2ec33abf58866..f8316d76c83818f0330e12db6a5bbfdeff443ad4 100644
--- a/src/features/list/list.component.html
+++ b/src/features/list/list.component.html
@@ -1,11 +1,33 @@
-<cdk-virtual-scroll-viewport itemSize="36"
-    class="virtual-scroll-viewport">
-    <button *cdkVirtualFor="let feature of features$ | async"
-        mat-button
-        class="virtual-scroll-item sxplr-w-100"
-        [matTooltip]="feature.name"
-        matTooltipPosition="right"
-        (click)="onClickItem(feature)">
-        {{ feature.name }}
-    </button>
-</cdk-virtual-scroll-viewport>
+<ng-template [ngIf]="datasource$ | async" let-datasource>
+
+    <cdk-virtual-scroll-viewport itemSize="36"
+        (scrolledIndexChange)="onScroll(datasource, $event)"
+        class="virtual-scroll-viewport">
+        
+        <ng-template cdkVirtualFor
+            [cdkVirtualForOf]="datasource"
+            let-feature
+            let-last="last"
+            let-index="index">
+
+            <button
+                mat-button
+                class="virtual-scroll-item sxplr-w-100"
+                [matTooltip]="feature.name"
+                matTooltipPosition="right"
+                (click)="onClickItem(feature)">
+                {{ feature.name }}
+            </button>
+
+            <div *ngIf="last && datasource.isPulling$ | async">
+                <ng-template [ngTemplateOutlet]="loadingSpinnerTmpl">
+                </ng-template>
+            </div>
+
+        </ng-template>
+    </cdk-virtual-scroll-viewport>
+</ng-template>
+
+<ng-template #loadingSpinnerTmpl>
+    <spinner-cmp class="sxplr-pl-2 sxplr-d-block"></spinner-cmp>
+</ng-template>
diff --git a/src/features/list/list.component.ts b/src/features/list/list.component.ts
index 29500aca749dc60f584165acfe66efd0a5469d66..f19f852f5cd96cd6a85165deecfcaa2e8a207c83 100644
--- a/src/features/list/list.component.ts
+++ b/src/features/list/list.component.ts
@@ -2,6 +2,7 @@ import { Component, EventEmitter, Output } from '@angular/core';
 import { SAPI } from 'src/atlasComponents/sapi';
 import { Feature } from 'src/atlasComponents/sapi/sxplrTypes';
 import { ListDirective } from './list.directive';
+import { IsAlreadyPulling, PulledDataSource } from 'src/util/pullable';
 
 @Component({
   selector: 'sxplr-feature-list',
@@ -21,4 +22,17 @@ export class ListComponent extends ListDirective {
   onClickItem(feature: Feature){
     this.onClickFeature.emit(feature)
   }
+
+  async onScroll(datasource: PulledDataSource<unknown>, scrollIndex: number){
+    if ((datasource.currentValue.length - scrollIndex) < 30) {
+      try {
+        await datasource.pull()
+      } catch (e) {
+        if (e instanceof IsAlreadyPulling) {
+          return
+        }
+        throw e
+      }
+    }
+  }
 }
diff --git a/src/features/list/list.directive.ts b/src/features/list/list.directive.ts
index a2a7353ec31049d15c09b42b7634b94b35110c48..119fae57b2a5933e760567cac51b274ff93132f3 100644
--- a/src/features/list/list.directive.ts
+++ b/src/features/list/list.directive.ts
@@ -1,16 +1,22 @@
-import { Input, Directive, SimpleChanges } from "@angular/core";
-import { BehaviorSubject, combineLatest, Observable, of, throwError } from "rxjs";
-import { catchError, switchMap, tap } from "rxjs/operators";
+import { Input, Directive, SimpleChanges, OnDestroy } from "@angular/core";
+import { BehaviorSubject, combineLatest, forkJoin, of, Subject, Subscription } from "rxjs";
+import { distinctUntilChanged, shareReplay, startWith, switchMap } from "rxjs/operators";
 import { SAPI } from "src/atlasComponents/sapi";
-import { Feature } from "src/atlasComponents/sapi/sxplrTypes";
 import { FeatureType } from "src/atlasComponents/sapi/typeV3";
 import { AllFeatures, FeatureBase } from "../base";
+import { PulledDataSource } from "src/util/pullable";
+import {
+  translateV3Entities
+} from "src/atlasComponents/sapi/translateV3"
+
+
+export type TranslatedFeature = Awaited< ReturnType<(typeof translateV3Entities)['translateFeature']> >
 
 @Directive({
   selector: '[sxplr-feature-list-directive]',
   exportAs: 'featureListDirective'
 })
-export class ListDirective extends FeatureBase{
+export class ListDirective extends FeatureBase  implements OnDestroy{
 
   @Input()
   name: string
@@ -22,10 +28,87 @@ export class ListDirective extends FeatureBase{
   featureRoute: string
   private guardedRoute$ = new BehaviorSubject<FeatureType>(null)
 
-  public state$ = new BehaviorSubject<'busy'|'noresult'|'result'>('noresult')
+  #total = new Subject<number>()
+  total$ = this.#total.pipe(
+    distinctUntilChanged(),
+  )
+  
+  private _datasource$ = new Subject<PulledDataSource<TranslatedFeature>>()
+  datasource$ = this._datasource$.asObservable().pipe(
+    shareReplay(1)
+  )
+
+  public isBusy$ = this.datasource$.pipe(
+    switchMap(ds => ds.isPulling$),
+    startWith(false)
+  )
+
+  #params = combineLatest([
+    this.guardedRoute$,
+    this.TPRBbox$,
+  ])
+
+  #subscription: Subscription[] = []
+
+  ngOnDestroy(): void {
+    while (this.#subscription.length > 0) this.#subscription.pop().unsubscribe()
+  }
 
   constructor(private sapi: SAPI) {
     super()
+    this.#subscription.push(
+      this.#params.subscribe(([ route, { template, parcellation, region, bbox } ]) => {
+        let page: number = 1
+        let totalPages: number = null
+        const datasource = new PulledDataSource({
+          pull: async () => {
+            if (totalPages && page > totalPages) {
+              return []
+            }
+            const query: any = {}
+            if (template) query['space_id'] = template.id
+            if (parcellation) query['parcellation_id'] = parcellation.id
+            if (region) query['region_id'] = region.name
+            if (bbox) query['bbox'] = JSON.stringify(bbox)
+
+            /**
+             * some routes, such as geneexpression, are specifically disabled
+             */
+            if (!route) {
+              totalPages = 0
+              this.#total.next(0)
+              return []
+            }
+
+            const results = await this.sapi.v3Get(`/feature/${route}`, {
+              query: {
+                ...this.queryParams,
+                ...query,
+                page
+              }
+            }).pipe(
+              switchMap(resp => {
+                totalPages = resp.pages || 0
+                this.#total.next(resp.total || 0)
+                if (resp.items.length === 0) {
+                  return of([] as TranslatedFeature[])
+                }
+                return forkJoin(
+                  resp.items.map(feature => translateV3Entities.translateFeature(feature))
+                )
+              })
+            ).toPromise()
+            page += 1
+            return results
+          },
+          annotations: {
+            ...this.queryParams,
+          }
+        })
+        this._datasource$.next(datasource)
+        datasource.pull()
+      })
+    )
   }
 
   ngOnChanges(sc: SimpleChanges): void {
@@ -36,32 +119,4 @@ export class ListDirective extends FeatureBase{
       this.guardedRoute$.next(AllFeatures[featureType])
     }
   }
-
-  public features$: Observable<Feature[]> = combineLatest([
-    this.guardedRoute$,
-    this.TPRBbox$,
-  ]).pipe(
-    tap(() => this.state$.next('busy')),
-    switchMap(([route, { template, parcellation, region, bbox }]) => {
-      if (!route) {
-        return throwError("noresult")
-      }
-      const query = {}
-      if (template) query['space_id'] = template.id
-      if (parcellation) query['parcellation_id'] = parcellation.id
-      if (region) query['region_id'] = region.name
-      if (bbox) query['bbox'] = JSON.stringify(bbox)
-      return this.sapi.getV3Features(route, {
-        query: {
-          ...this.queryParams,
-          ...query,
-        } as any
-      })
-    }),
-    catchError(() => {
-      this.state$.next("noresult")
-      return of([] as Feature[])
-    }),
-    tap(result => this.state$.next(result.length > 0 ? 'result' : 'noresult')),
-  )
 }
diff --git a/src/features/module.ts b/src/features/module.ts
index ba78304b9941fd6cafb9c1a85a53f2aa92be54d3..1e472a8dfb127c909fbeabb81d76b8674749a8fc 100644
--- a/src/features/module.ts
+++ b/src/features/module.ts
@@ -26,7 +26,6 @@ import { FilterCategoriesPipe } from "./filterCategories.pipe";
 import { ListDirective } from "./list/list.directive";
 import { MatChipsModule } from "@angular/material/chips";
 import { FeatureFilterDirective } from "./feature.filter.directive";
-import { FilterGroupList } from "./filterGrpFeat.directive"
 import { GroupFeaturesToName } from "./grpFeatToName.pipe";
 
 @NgModule({
@@ -56,7 +55,6 @@ import { GroupFeaturesToName } from "./grpFeatToName.pipe";
     FilterCategoriesPipe,
     ListDirective,
     FeatureFilterDirective,
-    FilterGroupList,
 
     CategoryAccDirective,
     VoiBboxDirective,
diff --git a/src/util/pullable.ts b/src/util/pullable.ts
new file mode 100644
index 0000000000000000000000000000000000000000..2c041f3092070f96e8a09675475c88aa3f5ca78e
--- /dev/null
+++ b/src/util/pullable.ts
@@ -0,0 +1,159 @@
+import { DataSource } from "@angular/cdk/collections"
+import { BehaviorSubject, Observable, ReplaySubject, Subscription, combineLatest, concat, of, pipe } from "rxjs"
+import { finalize, map, scan, shareReplay, startWith, tap } from "rxjs/operators"
+
+export interface IPuller<T> {
+  next: (cb: (val: T) => void) => void
+  complete: (cb: () => void) => void
+}
+
+interface PaginatedArg<T> {
+  pull?: () => Promise<T[]>
+  children?: PulledDataSource<T>[]
+  annotations?: Record<string, string>
+}
+
+export class IsAlreadyPulling extends Error {}
+
+
+/**
+ * Modifed Datasource
+ * Allowing pull driven datasource
+ * With backwards compatibility with original datasource.
+ */
+export class PulledDataSource<T> extends DataSource<T> {
+
+  protected annotations: Record<string, string>
+
+  protected onPulled() {
+    return pipe(
+    )
+  }
+
+  #pull: () => Promise<T[]>
+  
+  completed = false
+  private _data = new ReplaySubject<T[]>()
+  currentValue: T[] = []
+  finalValue: T[] = []
+
+  protected _isPulling = false
+  protected _isPulling$ = new BehaviorSubject<boolean>(false)
+  isPulling$ = this._isPulling$.pipe(
+    shareReplay(1)
+  )
+  set isPulling(val: boolean) {
+    this._isPulling = val
+    this._isPulling$.next(val)
+  }
+  get isPulling(){
+    return this._isPulling
+  }
+
+
+  async pull(): Promise<T[]> {
+    if (this.completed) {
+      return []
+    }
+    if (this.isPulling) {
+      throw new IsAlreadyPulling(`PulledDataSource is already pulling`)
+    }
+
+    if (!this.#pull) {
+      return []
+    }
+    this.isPulling = true
+    const newResults = await this.#pull()
+    this.isPulling = false
+    if (newResults.length === 0) {
+      this.complete()
+      return []
+    }
+    this._data.next(newResults)
+    return newResults
+  }
+
+  constructor(arg?: PaginatedArg<T>){
+    super()
+    const { pull, annotations } = arg || {}
+    if (!pull) {
+      throw new Error(`pull method must be provided for PulledDataSource`)
+    }
+    this.#pull = pull
+    this.annotations = annotations
+    
+  }
+
+  connect(): Observable<readonly T[]> {
+    return this._data.pipe(
+      startWith([] as T[]),
+      scan((acc, curr) => [...acc, ...curr]),
+      tap((v: T[]) => {
+        this.currentValue = v
+      }),
+    )
+  }
+  complete() {
+    this.completed = true
+    // must assign final value synchronously
+    this.finalValue = this.currentValue || []
+    this._data.complete()
+  }
+  disconnect(): void {
+    
+  }
+}
+
+export class ParentDatasource<T> extends PulledDataSource<T> {
+
+  #subscriptions: Subscription[] = []
+  _children: PulledDataSource<T>[] = []
+  constructor(arg: PaginatedArg<T>){
+    super({ pull: async () => [], annotations: arg.annotations })
+    const { children } = arg
+    this._children = children
+  }
+
+  async pull() {
+    for (const ds of this._children) {
+      if (!ds.completed) {
+        return await ds.pull()
+      }
+    }
+    return []
+  }
+
+  connect(): Observable<readonly T[]> {
+    if (this._children.length === 0) {
+      return of([] as T[])
+    }
+
+    this.#subscriptions.push(
+      combineLatest(this._children.map(c => c.isPulling$)).subscribe(flags => {
+        this.isPulling = flags.some(flag => flag)
+      })
+    )
+
+    return concat(
+      ...this._children.map(ds => ds.connect())
+    ).pipe(
+      map(arr => {
+        const alreadyEmitted = this._children.filter(c => c.completed)
+        const prevValues = alreadyEmitted.flatMap(v => v.finalValue)
+        return [...prevValues, ...arr]
+      }),
+      
+      tap((v: T[]) => {
+        this.currentValue = v
+      }),
+      finalize(() => {
+        this.finalValue = this.currentValue || []
+      })
+    )
+  }
+
+  disconnect(): void {
+    super.disconnect()
+    while (this.#subscriptions.length > 0) this.#subscriptions.pop().unsubscribe()
+  }
+}
diff --git a/src/viewerModule/nehuba/ngLayerCtlModule/ngLayerCtl/ngLayerCtrl.style.css b/src/viewerModule/nehuba/ngLayerCtlModule/ngLayerCtl/ngLayerCtrl.style.css
index f2fe15fbc12c4dd49e16beae173aaa011bd6bd49..ea70c88edb882e6324f85fa65b46e9eb754d6b29 100644
--- a/src/viewerModule/nehuba/ngLayerCtlModule/ngLayerCtl/ngLayerCtrl.style.css
+++ b/src/viewerModule/nehuba/ngLayerCtlModule/ngLayerCtl/ngLayerCtrl.style.css
@@ -3,7 +3,7 @@
     padding: 0.5rem 0;
 }
 
-.container
+.nglayer-container
 {
     display: flex;
     width: 100%;
diff --git a/src/viewerModule/nehuba/ngLayerCtlModule/ngLayerCtl/ngLayerCtrl.template.html b/src/viewerModule/nehuba/ngLayerCtlModule/ngLayerCtl/ngLayerCtrl.template.html
index cb90abf4dff38ab1448b31dc0e2427bcc769e523..6ed7afca62d830a4270009e9b5fd1f701aff7676 100644
--- a/src/viewerModule/nehuba/ngLayerCtlModule/ngLayerCtl/ngLayerCtrl.template.html
+++ b/src/viewerModule/nehuba/ngLayerCtlModule/ngLayerCtl/ngLayerCtrl.template.html
@@ -1,4 +1,4 @@
-<div class="container" [ngClass]="{ 'text-muted': !visible }">
+<div class="nglayer-container" [ngClass]="{ 'text-muted': !visible }">
 
   <button mat-icon-button
     [matTooltip]="CONST.TOGGLE_LAYER_VISILITY"
diff --git a/src/viewerModule/viewerCmp/viewerCmp.template.html b/src/viewerModule/viewerCmp/viewerCmp.template.html
index f71a92fe0c1012d5811c9b9fa13d691fa4e1ffb0..19914ede12cbe2b99d2be1d8b227000aefca2dd7 100644
--- a/src/viewerModule/viewerCmp/viewerCmp.template.html
+++ b/src/viewerModule/viewerCmp/viewerCmp.template.html
@@ -1009,12 +1009,17 @@
       </span>
     </ng-template>
   </button>
-  <div
+
+  <!-- TODO voiBbox directive is used to draw outlines for VOI
+  this has been temporarily disabled, since datasource is paginated 
+  and how bounding boxes are drawn needs to be reconsidered -->
+  
+  <!-- <div
     *ngIf="voiSwitch.switchState$ | async"
     voiBbox
     [features]="voiFeatureEntryCmp.features$ | async">
 
-  </div>
+  </div> -->
 </ng-template>
 
 <div