Among the pieces I have written, one that drew a lot of readers was Angular: MatPaginator Custom Styling. In it, I walked through turning Angular Material's paginator on a mat-table into something visually nicer by attaching a custom directive.
Two significant changes since then pushed me to revisit that post. It was based on Angular v14, and afterwards the Angular Material MDC components landed, which caused no small amount of pain for anyone upgrading, plus Angular itself picked up some smaller improvements. My earlier approach also reached into private methods of MatPaginator. The GIF below shows the result we are aiming for, and the complete source is available in this GitHub repository.
The complete code comes first so you can refer back to it whenever needed, and after that I'll unpack the parts that deserve a closer look or aren't immediately obvious. The starting point is presumably a table already rendering data, roughly like this:
import { afterNextRender, Component, viewChild } from '@angular/core';
import { MatPaginator, MatPaginatorModule } from '@angular/material/paginator';
import { MatTableDataSource, MatTableModule } from '@angular/material/table';
type Data = { position: number; name: string; weight: number; symbol: string };
@Component({
selector: 'app-test-table',
imports: [MatTableModule, MatPaginatorModule, BubblePaginationDirective],
template: `
<table mat-table [dataSource]="dataSource">
<!-- Position Column -->
<ng-container matColumnDef="position">
<th mat-header-cell *matHeaderCellDef>No.</th>
<td mat-cell *matCellDef="let element">{{ element.position }}</td>
</ng-container>
<!-- ... more columns ... -->
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns"></tr>
</table>
<mat-paginator [length]="dataSource.data.length" [pageSize]="10" />
`,
})
export class TestTableComponent {
readonly dataSource = new MatTableDataSource<Data>([]);
readonly paginator = viewChild(MatPaginator);
readonly displayedColumns: string[] = ['position', 'name', 'weight', 'symbol'];
constructor() {
const data: Data[] = [];
Array.from({ length: 100 }, (_, k) => k + 1).forEach(v => {
data.push({ position: v, name: `Element ${v}`, weight: v * 1.5, symbol: `E${v}` });
});
this.dataSource.data = data;
afterNextRender(() => {
const paginator = this.paginator();
if (paginator) {
this.dataSource.paginator = paginator;
}
});
}
}
That minimal setup renders a table looking like this:
Our target is a directive that gets attached to the mat-paginator and converts the paginated table into the bubble variant shown earlier. Here is how the directive gets used:
<mat-paginator
[appBubblePagination]="dataSource.data.length"
(page)="onPageChange($event)"
[length]="dataSource.data.length"
[pageSize]="15">
</mat-paginator>
Below is the directive in full, followed by notes on selected sections.
import { Directive, ElementRef, Renderer2, afterRenderEffect, inject, input, untracked } from '@angular/core';
import { MatPaginator } from '@angular/material/paginator';
/**
* Works from angular-material version 15. since all classes got the new prefix 'mdc-'
*/
@Directive({
selector: '[appBubblePagination]',
})
export class BubblePaginationDirective {
private readonly matPag = inject(MatPaginator, {
optional: true,
self: true,
host: true,
});
private readonly elementRef = inject(ElementRef);
private readonly ren = inject(Renderer2);
/**
* whether we want to display first/last button and dots
*/
readonly showFirstButton = input(true);
readonly showLastButton = input(true);
/**
* total number of items in pagination
* needed to calculate how many buttons to render
* when page size changes
*/
readonly paginationSize = input(0, {
alias: 'appBubblePagination',
});
/**
* how many buttons to display before and after
* the selected button
*/
readonly renderButtonsNumber = input(2);
/**
* references to DOM elements
*/
private dotsEndRef!: HTMLElement;
private dotsStartRef!: HTMLElement;
private bubbleContainerRef!: HTMLElement;
/**
* ref to rendered buttons on UI that we can remove them size changes
*/
private buttonsRef: HTMLElement[] = [];
readonly buildButtonsEffect = afterRenderEffect(() => {
// rebuild buttons when pagination size change
this.paginationSize();
untracked(() => {
// remove buttons before creating new ones
this.removeButtons();
// set some default styles to mat pagination
this.styleDefaultPagination();
// create bubble container
this.createBubbleDivRef();
// create all buttons
this.buildButtons();
// switch back to page 0
this.switchPage(0);
});
});
/**
* change the active button style to the current one and display/hide additional buttons
* based on the navigated index
*/
private changeActiveButtonStyles(previousIndex: number, newIndex: number) {
const previouslyActive = this.buttonsRef[previousIndex];
const currentActive = this.buttonsRef[newIndex];
if (!previouslyActive && !currentActive) {
return;
}
// remove active style from previously active button
if (previouslyActive) {
this.ren.removeClass(previouslyActive, 'g-bubble__active');
}
// add active style to new active button
this.ren.addClass(currentActive, 'g-bubble__active');
// hide all buttons
this.buttonsRef.forEach(button => this.ren.setStyle(button, 'display', 'none'));
// show N previous buttons and X next buttons
const renderElements = this.renderButtonsNumber();
const endDots = newIndex < this.buttonsRef.length - renderElements - 1;
const startDots = newIndex - renderElements > 0;
const firstButton = this.buttonsRef[0];
const lastButton = this.buttonsRef[this.buttonsRef.length - 1];
// last bubble and dots
if (this.showLastButton()) {
this.ren.setStyle(this.dotsEndRef, 'display', endDots ? 'block' : 'none');
this.ren.setStyle(lastButton, 'display', endDots ? 'flex' : 'none');
}
// first bubble and dots
if (this.showFirstButton()) {
this.ren.setStyle(this.dotsStartRef, 'display', startDots ? 'block' : 'none');
this.ren.setStyle(firstButton, 'display', startDots ? 'flex' : 'none');
}
// resolve starting and ending index to show buttons
const startingIndex = startDots ? newIndex - renderElements : 0;
const endingIndex = endDots ? newIndex + renderElements : this.buttonsRef.length - 1;
// display starting buttons
for (let i = startingIndex; i <= endingIndex; i++) {
const button = this.buttonsRef[i];
this.ren.setStyle(button, 'display', 'flex');
}
}
/**
* Removes or change styling of some html elements
*/
private styleDefaultPagination() {
const nativeElement = this.elementRef.nativeElement;
const itemsPerPage = nativeElement.querySelector('.mat-mdc-paginator-page-size');
const howManyDisplayedEl = nativeElement.querySelector('.mat-mdc-paginator-range-label');
const previousButton = nativeElement.querySelector('button.mat-mdc-paginator-navigation-previous');
const nextButtonDefault = nativeElement.querySelector('button.mat-mdc-paginator-navigation-next');
// remove 'items per page'
if (itemsPerPage) {
this.ren.setStyle(itemsPerPage, 'display', 'none');
}
// style text of how many elements are currently displayed
if (howManyDisplayedEl) {
this.ren.setStyle(howManyDisplayedEl, 'position', 'absolute');
this.ren.setStyle(howManyDisplayedEl, 'color', '#919191');
this.ren.setStyle(howManyDisplayedEl, 'font-size', '14px');
this.ren.setStyle(howManyDisplayedEl, 'left', '-0');
}
// check whether to remove left & right default arrows
this.ren.setStyle(previousButton, 'display', 'none');
this.ren.setStyle(nextButtonDefault, 'display', 'none');
}
/**
* creates `bubbleContainerRef` where all buttons will be rendered
*/
private createBubbleDivRef(): void {
const actionContainer = this.elementRef.nativeElement.querySelector('div.mat-mdc-paginator-range-actions');
const nextButtonDefault = this.elementRef.nativeElement.querySelector('button.mat-mdc-paginator-navigation-next');
// create a HTML element where all bubbles will be rendered
this.bubbleContainerRef = this.ren.createElement('div') as HTMLElement;
this.ren.addClass(this.bubbleContainerRef, 'g-bubble-container');
// render element before the 'next button' is displayed
this.ren.insertBefore(actionContainer, this.bubbleContainerRef, nextButtonDefault);
}
/**
* helper function that builds all button and add dots
* between the first button, the rest and the last button
*
* end result: (1) .... (4) (5) (6) ... (25)
*/
private buildButtons(): void {
if (!this.matPag) {
return;
}
const neededButtons = Math.ceil(this.matPag.length / this.matPag.pageSize);
// if there is only one page, do not render buttons
if (neededButtons === 0 || neededButtons === 1) {
this.ren.setStyle(this.elementRef.nativeElement, 'display', 'none');
return;
}
// set back from hidden to block
this.ren.setStyle(this.elementRef.nativeElement, 'display', 'block');
// create first button
this.buttonsRef = [this.createButton(0)];
// add dots (....) to UI
this.dotsStartRef = this.createDotsElement();
// create all buttons needed for navigation (except the first & last one)
for (let index = 1; index < neededButtons - 1; index++) {
this.buttonsRef = [...this.buttonsRef, this.createButton(index)];
}
// add dots (....) to UI
this.dotsEndRef = this.createDotsElement();
// create last button to UI after the dots (....)
this.buttonsRef = [...this.buttonsRef, this.createButton(neededButtons - 1)];
}
/**
* Remove all buttons from DOM
*/
private removeButtons(): void {
this.buttonsRef.forEach(button => {
this.ren.removeChild(this.bubbleContainerRef, button);
});
// remove dots
if (this.dotsStartRef) {
this.ren.removeChild(this.bubbleContainerRef, this.dotsStartRef);
}
if (this.dotsEndRef) {
this.ren.removeChild(this.bubbleContainerRef, this.dotsEndRef);
}
// Empty state array
this.buttonsRef.length = 0;
}
/**
* create button HTML element
*/
private createButton(i: number): HTMLElement {
const bubbleButton = this.ren.createElement('div');
const text = this.ren.createText(String(i + 1));
// add class & text
this.ren.addClass(bubbleButton, 'g-bubble');
this.ren.setStyle(bubbleButton, 'margin-right', '8px');
this.ren.appendChild(bubbleButton, text);
// react on click
this.ren.listen(bubbleButton, 'click', () => {
this.switchPage(i);
});
// render on UI
this.ren.appendChild(this.bubbleContainerRef, bubbleButton);
// set style to hidden by default
this.ren.setStyle(bubbleButton, 'display', 'none');
return bubbleButton;
}
/**
* helper function to create dots (....) on DOM indicating that there are
* many more bubbles until the last one
*/
private createDotsElement(): HTMLElement {
const dotsEl = this.ren.createElement('span');
const dotsText = this.ren.createText('.....');
// add class
this.ren.setStyle(dotsEl, 'font-size', '18px');
this.ren.setStyle(dotsEl, 'margin-right', '8px');
this.ren.setStyle(dotsEl, 'padding-top', '6px');
this.ren.setStyle(dotsEl, 'color', '#919191');
// append text to element
this.ren.appendChild(dotsEl, dotsText);
// render dots to UI
this.ren.appendChild(this.bubbleContainerRef, dotsEl);
// set style none by default
this.ren.setStyle(dotsEl, 'display', 'none');
return dotsEl;
}
/**
* Helper function to switch page
*/
private switchPage(i: number): void {
if (!this.matPag) {
return;
}
const previousPageIndex = this.matPag.pageIndex;
// switch page index of mat paginator
this.matPag.pageIndex = i;
// change active button styles
this.changeActiveButtonStyles(previousPageIndex, this.matPag.pageIndex);
// need to trigger page event manually, because we are changing pageIndex programmatically
this.matPag.page.emit({
pageIndex: i,
pageSize: this.matPag.pageSize,
length: this.matPag.length,
previousPageIndex: previousPageIndex,
});
}
}
What Gets Injected
inject(MatPaginator, { optional: true, self: true, host: true })
inject(ElementRef)
inject(Renderer2)
-
MatPaginator— gives us access to the currentpageIndex, thepageSize, and thepagestream -
ElementRef— the paginator's root element, which we need in order to query what's inside it -
Renderer2— creates elements, applies styles and classes, and wires up event listeners the safe way. It cooperates well with SSR and keeps us away from direct DOM APIs
Signal Inputs and Outputs
-
showFirstButton = input(true);&showLastButton = input(true);— toggle the first and last buttons, which jump straight to either end of the table -
renderButtonsNumber = input(2);— while sitting on an active page, say index6, this decides how many buttons appear on each side of it -
paginationSize— a required input, because it's what tells us the table length changed, whether from filtering or freshly loaded data. A new value triggers a re-render of the bubbles so the page count stays correct
Where the Main Logic Runs
The directive spans roughly 300 lines, yet almost everything worth understanding sits in the buildButtonsEffect effect, for instance:
readonly buildButtonsEffect = afterRenderEffect(() => {
// rebuild buttons when pagination size change
this.paginationSize();
untracked(() => {
// remove buttons before creating new ones
this.removeButtons();
// set some default styles to mat pagination
this.styleDefaultPagination();
// create bubble container
this.createBubbleDivRef();
// create all buttons
this.buildButtons();
// switch back to page 0
this.switchPage(0);
});
});
The nice property of this effect is that it runs again whenever the length of the elements changes. Picture a single table backed by server-side filtering: every time a new batch of data shows up, the paginationSize signal input causes the bubbles to be recalculated. A short outline follows.
-
paginationSize— react to page size changes (incoming data) by recalculating the bubbles -
removeButtons()— wipe the custom DOM left behind by a previous run -
styleDefaultPagination()— hide the parts of the default Material markup we don't want and position the labels -
createBubbleDivRef()— set up the container that will hold the bubbles -
buildButtons()— produce bubbles and dots derived from the total length and page size -
switchPage(0)— begin at page 0, which keeps the behaviour predictable
You might wonder why afterRenderEffect is used rather than effect. The answer is scope: afterRenderEffect only fires in the browser, while effect would also execute during server-side rendering, which can throw errors if your app supports SSR. I cover the topic at length in my afterRenderEffect, afterNextRender, afterEveryRender & Renderer2 article.
Where the Main Logic Runs - Driving the Page Change
Because the custom bubbles sit outside Angular Material's own controls, clicking one does nothing on its own inside MatPaginator. The click has to be connected to the paginator so the table (and anything subscribed to matPag.page) responds. That's precisely what the switchPage() function exists for.
private createButton(index: number): HTMLElement {
// our unique bubble showing a specific page - 1, 2, etc.
const bubbleButton = this.ren.createElement('div');
// ... some code ...
this.ren.listen(bubbleButton, 'click', () => {
this.switchPage(index);
});
// ... some code ...
}
private switchPage(index: number): void {
if (!this.matPag) {
return;
}
const previousPageIndex = this.matPag.pageIndex;
// switch page index of mat paginator
this.matPag.pageIndex = index;
// change active button styles
this.changeActiveButtonStyles(previousPageIndex, this.matPag.pageIndex);
// trigger page event manually, we are changing pageIndex programmatically
this.matPag.page.emit({
pageIndex: index,
pageSize: this.matPag.pageSize,
length: this.matPag.length,
previousPageIndex: previousPageIndex,
});
}
The line doing the heavy lifting here is this.matPag.pageIndex = index;, which keeps the paginator's internal state aligned with whichever bubble was clicked. Delete it and pagination simply stops working.
Then, because that index is changed programmatically rather than through the UI, walking the items in the table won't make this.matPag.page emit anything. That data therefore has to be emitted by hand as well.
Touching the Paginator's Styles
styleDefaultPagination reaches straight into the paginator's internal HTML elements to restyle them. Ideal? Probably not. It depends on exact class selectors such as button.mat-mdc-paginator-navigation-next, which makes it fragile — Material is free to rename these internals in a future release.
That's exactly what happened when Material v15 shipped the new MDC components and broke custom styling across many projects relying on ::ng-deep. What happens here is a hack of the same family, though for this particular problem it remains the most practical option. The tradeoff is still worth knowing about.
Styling the Bubbles
Logic alone won't make it look like bubbles — styling is still required. This portion is essentially CSS (or SCSS) and is easy to adapt to your own theme. In the example, every bubble is a plain flex container with centered text, a hover state, and an active state that marks the current page.
/* Custom paginator styles */
.g-bubble-container {
display: flex;
gap: 4px;
}
.g-bubble {
background-color: #f0f0f0;
border-radius: 50%;
width: 34px;
height: 34px;
display: flex;
align-items: center;
justify-content: center;
color: #2e2e2e;
font-size: 14px;
cursor: pointer;
transition: 0.3s;
&:hover {
background-color: #636363;
color: orange;
}
}
.g-bubble__active {
background-color: #636363;
color: orange;
}
mat-paginator {
background: transparent !important;
/* need mat-paginator range to align with other mat-table elements */
position: relative;
}
/* override alignment for the labels that shows "x of y" */
.mat-mdc-paginator-range-label {
margin: 0 !important;
}
Points Worth Remembering
A handful of smaller details deserve attention:
- Renderer2 limits — pseudo-classes (
::before,::after) can't be applied from inside the directive, so visual parts belong in SCSS files - Angular Material internals — as noted above,
mat-mdc-selectors may change in later Material versions; that's one of the risks this directive carries - Accessibility (a11y) — these are custom clickable divs, so consider adding
role="button"andtabindex="0"so keyboard users can move through the bubbles. Listening forkeydownevents to mimic clicks on space or enter is another option - SSR / Hydration — under Angular SSR the directive should keep working, because
Renderer2is SSR safe andafterRenderEffectrestricts bubble rendering to the client - Active Button State — when more data is loaded and the bubble rendering logic re-runs, we currently return to the first page, so an active state hasn't been implemented yet
- The input
[appBubblePaginationLength]is important for bounding the table length. Without it, theafterRenderEffectnever learns that new data arrived, and the buttons won't be rebuilt
Wrapping Up
After following along, you should have a paginator that works and is MDC compatible. Replacing Angular Material was never the aim — the point was to demonstrate how much can be achieved with directives plus Renderer2 to enhance a Material component that already exists, instead of writing a paginator from scratch.
There is surely room to improve it, as it remains a fairly simple directive, so suggestions are welcome in the comments. I hope the example was useful; you can find more of my writing on dev.to, reach me on LinkedIn, or visit my Personal Website.


