Introduction We have received a couple of requests from our community for the ability to freeze columns and rows in Pivot widgets. To address this need while we still don't have it as a built-in feature, I have created a script and accompanying article to help you achieve this functionality. Here you will learn how to freeze (pin) the header row and the first columns of a Sisense Pivot 2.0 widget so they stay visible while scrolling large tables. Covers both the native Sisense freeze option and a custom widget script for cases the native feature doesn't cover. Applies to both Cloud and on-prem. Tested on version 2026.2.2-d2. Step-by-Step Guide Option 1: Native Sisense pivot freezing (recommended when it applies) Before using any custom script, check whether Sisense's built-in freezing covers your need - it is the most stable and maintainable option since it's a native feature. Sisense Pivot 2.0 does not have a dedicated "freeze" toggle in the widget design. Instead, freezing happens automatically as part of the pivot's own rendering and scrolling logic, under two conditions: Native vertical freezing (header row): The header row stays pinned automatically when the pivot has to scroll its content vertically. The key is that the widget must be constrained to a height smaller than the total content, so the table scrolls internally rather than expanding to fit everything. Native horizontal freezing (Rows fields): During horizontal scroll, Sisense automatically keeps the fields you added to the pivot's Rows panel pinned on the left, while the fields in the Values panel scroll horizontally. In other words, your row dimensions (for example, Brand and Category) stay in place and only the value columns move. This native horizontal freezing is only useful when your pivot has few Rows fields and many Values - the row dimensions stay visible while you scroll through the many value columns. How to enable native freezing (via auto height + widget height): Open the dashboard and enter edit mode. Open the Pivot 2.0 widget. Go to the widget's Design panel. Disable the Auto Height option. When auto height is on, the widget grows to fit all its content, so there is no internal scroll - and therefore nothing to freeze against. Set the widget height to a value smaller than the amount of content you can see in your screen . This forces the pivot to scroll internally. Apply and save. With auto height off and a constrained height, the header row remains pinned during vertical scroll, and the Rows fields remain pinned during horizontal scroll. Conditions and limitations of native freezing: This behavior relies on the widget having a height smaller than its content so it scrolls internally. If auto height is enabled, the header will not stay pinned. Vertical: the header row is pinned during vertical scroll. Horizontal: only the fields in the Rows panel are pinned during horizontal scroll - the Values columns scroll. This is only helpful when you have few Rows fields and many Values. If your requirement falls outside this (for example, freezing a specific number of leftmost visual columns regardless of whether they are Rows or Values, or a layout the native logic doesn't handle), use the custom script in Option 2. > [ GIF Description: native Sisense pivot freezing in action - show the header row staying pinned during vertical scroll, and the Rows fields staying pinned while the Values columns scroll horizontally.] Option 2: Custom widget script (when native freezing doesn't cover the use case) Use this approach only when the native freezing option does not achieve what you need - for example, when you need to freeze a specific number of leftmost visual columns rather than only the Rows fields, or when your pivot has many rows and the native Rows-based horizontal freezing does not fit your layout. When a pivot table has many rows and columns, scrolling causes the header row and the leftmost identifying columns (for example, Brand and Category) to scroll out of view, making it hard to read the data. This solution uses a widget script that mirrors the same 4-pane model Sisense itself uses internally. It clones the pivot table into fixed overlay panes, each cropped to show only the frozen region: Top pane - shows only the header row (cropped to header height) Left pane - shows only the first N columns (cropped to their width) Corner pane - shows the top-left intersection of both Each pane is a full clone of the table, so the cells keep their content and layout. As the user scrolls, the clones are shifted in the opposite direction to stay aligned with the real table underneath. Why a MutationObserver is used Pivot 2.0 does not re-fire the ready event when the user paginates - it swaps the table body in place. This means a one-time clone would become stale (it would keep showing page 1 values while the user is on page 2). To handle this, the script rebuilds the panes whenever the underlying table changes, using a MutationObserver , and also re-aligns them on scroll. How to apply the script Open the dashboard and enter edit mode. Open the Pivot 2.0 widget you want to freeze. In the widget's Design panel, disable the Auto Height option and set the widget height to a value smaller than its total content. You can set the height in one of two ways: by entering a number in the manual row height field in the widget Design panel, or by manually resizing the widget - clicking and dragging its edge - as long as the size you set is not bigger than the amount of content you can see on your screen. As explained in Option 1, this forces the pivot to scroll internally, which is what keeps the header row pinned. The script relies on this internal scroll to align the frozen panes. Click the widget menu (three dots) and select Edit Script . Paste the script below into the widget script editor. Adjust the FROZEN_COLS value at the top to match how many leftmost columns you want to freeze (the example freezes 2 columns). Save the script and refresh the widget. The script /**
* Freeze first row + first column(s) of a Pivot 2.0 widget — cropped-clone panes.
*
* APPROACH: mirror Sisense's own 4-pane model. Clone the ENTIRE pivot table
* into fixed overlay panes, each cropped with overflow:hidden so it shows only
* the frozen region:
* - TOP pane -> header row only (cropped to header height)
* - LEFT pane -> first N columns only (cropped to their width)
* - CORNER pane -> top-left intersection
* Each pane is a full <table> clone, so cells keep content + layout. Panes are
* pinned; the inner clone is translated opposite to scroll to stay aligned.
*
* IMPORTANT: Pivot 2.0 does NOT re-fire `ready` on pagination — it swaps the
* table body in place. So a one-time clone goes stale (shows page 1 values on
* page 2). We therefore REBUILD the panes whenever the real table mutates,
* via a MutationObserver, and also on scroll for alignment.
*
* CONFIG
*/
var FROZEN_COLS = 2; // Brand + Category (col-0, col-1)
var FREEZE_BORDER = '1px solid #c6cbd4'; // frozen-edge divider (match theme gridline)
widget.on('ready', function () {
var $el = $(element);
var observer = null;
var rebuildTimer = null;
function getView() {
var $v = $el.find('.pivot-scroller__view').first();
return $v.length ? $v : null;
}
function buildPanes() {
var $view = getView();
if (!$view) return;
var view = $view[0];
var $realTable = $view.find('table.table-grid__table').first();
if (!$realTable.length) return;
// Remove previous panes before rebuilding (prevents stacking / staleness)
var $scroller = $el.find('.pivot-scroller').first();
$scroller.find('.freeze-pane').remove();
$scroller.css('position', 'relative');
// Measure header height + frozen-column width from the CURRENT table
var $headerRow = $realTable.find('tr.table-grid__row-0').first();
var headerH = $headerRow.length ? $headerRow[0].getBoundingClientRect().height : 0;
var frozenW = 0;
for (var c = 0; c < FROZEN_COLS; c++) {
var $cell = $realTable.find('td[class*="table-grid__cell--col-' + c + '"]').first();
if ($cell.length) frozenW += $cell[0].getBoundingClientRect().width;
}
if (!headerH || !frozenW) return;
function makePane(kind, cropW, cropH, zIndex) {
var $pane = $('<div class="freeze-pane freeze-pane--' + kind + '"></div>').css({
position: 'absolute',
top: 0,
left: 0,
width: (cropW ? cropW + 'px' : '100%'),
height: (cropH ? cropH + 'px' : '100%'),
overflow: 'hidden',
'pointer-events': 'none',
background: '#fff',
'box-sizing': 'content-box', // border sits OUTSIDE cropW; no column pixels clipped
'z-index': zIndex
});
// Fresh clone of the CURRENT table state (current page's rows)
var $clone = $realTable.clone();
$clone.css({ position: 'absolute', top: 0, left: 0, margin: 0 });
$pane.append($clone);
$pane.data('clone', $clone);
return $pane;
}
var $left = makePane('left', frozenW, null, 4);
var $top = makePane('top', null, headerH, 5);
var $corner = makePane('corner', frozenW, headerH, 6);
// Frozen-edge dividers. overflow:hidden on each pane clips the
// clone's own border, so we draw the edge on the PANE itself:
// - right edge -> frozen COLUMN boundary (left + corner panes)
// - bottom edge -> frozen HEADER boundary (top + corner panes)
$left.css('border-right', FREEZE_BORDER);
$top.css('border-bottom', FREEZE_BORDER);
$corner.css({ 'border-right': FREEZE_BORDER, 'border-bottom': FREEZE_BORDER });
$scroller.append($left).append($top).append($corner);
function sync() {
var x = view.scrollLeft;
var y = view.scrollTop;
$left.data('clone').css('transform', 'translate(0px,' + (-y) + 'px)'); // pin X, follow Y
$top.data('clone').css('transform', 'translate(' + (-x) + 'px,0px)'); // pin Y, follow X
$corner.data('clone').css('transform', 'translate(0px,0px)'); // pin both
}
$view.off('scroll.freeze').on('scroll.freeze', sync);
sync();
}
// Debounced rebuild so rapid DOM mutations (pagination render) collapse to one pass
function scheduleRebuild() {
clearTimeout(rebuildTimer);
rebuildTimer = setTimeout(buildPanes, 30);
}
// Watch a STABLE ancestor for content swaps (pagination, sort, filter re-render).
// The inner <table> inside .pivot-scroller__view is REPLACED on pagination, so
// observing the table (or its immediate parent) stops firing once it's swapped.
// .sisense-pivot / .multi-grid persist across re-renders, so observe there.
function attachObserver() {
var target = $el.find('.sisense-pivot').first()[0] ||
$el.find('.multi-grid').first()[0] ||
$el.find('.pivot-container').first()[0];
if (!target) return;
if (observer) observer.disconnect();
observer = new MutationObserver(function (mutations) {
// Ignore mutations that are only our own overlay panes, to avoid an
// observe -> rebuild -> mutate -> observe feedback loop.
function isPane(n) {
return n && n.nodeType === 1 && n.className &&
String(n.className).indexOf('freeze-pane') !== -1;
}
function nodesArePanes(list) {
if (!list || !list.length) return true; // nothing added/removed
for (var i = 0; i < list.length; i++) {
if (!isPane(list[i])) return false;
}
return true;
}
var relevant = mutations.some(function (m) {
if (isPane(m.target)) return false; // change inside a pane
if (m.target && isPane(m.target.parentNode)) return false;
// If this mutation only added/removed panes, ignore it
return !(nodesArePanes(m.addedNodes) && nodesArePanes(m.removedNodes));
});
if (relevant) scheduleRebuild();
});
observer.observe(target, { childList: true, subtree: true, characterData: true });
}
buildPanes();
attachObserver();
}); Customizing the script FROZEN_COLS - Set this to the number of leftmost columns you want to keep frozen. For example, set it to 1 to freeze only the first column, or 3 to freeze the first three. FREEZE_BORDER - Controls the divider line on the frozen-column edge. Adjust the color to match your dashboard theme's gridlines. > [GIF Description: custom widget script in action - show the header row and first columns staying frozen while scrolling both vertically and horizontally, and remaining correct after pagination.] Conclusion Sisense Pivot 2.0 has its own internal rendering and scrolling logic - it decides how the table is drawn and scrolled, and under certain conditions it will freeze rows and columns automatically as part of that logic while we do not have the option to change it natively. Specifically, when the widget has a fixed height smaller than its content, the header row stays pinned during vertical scroll; and during horizontal scroll, the fields in the Rows panel stay pinned while the Values columns scroll (which is most useful when you have few Rows fields and many Values). This is why the native behavior should always be your first choice when it covers your use case: it works with the pivot's own engine, making it the most stable and maintainable approach. When the native behavior does not fit your specific requirement (for example, freezing a specific number of leftmost visual columns rather than only the Rows fields), the custom widget script replicates the behavior by cloning the table into cropped, pinned overlay panes for the header row, first columns, and their intersection. Because Pivot 2.0 swaps the table body in place during pagination, sorting, and filtering, the script uses a MutationObserver to rebuild the frozen panes automatically, keeping them accurate as the data changes. Adjust FROZEN_COLS to control how many columns stay pinned. Please note that this method may not cover every possible scenario or combination of features as it was developed thinking in simple scenarios, so experiencing issues in certain situations is a possibility. References/Related Content Customizing Sisense using JavaScript Pivot 2.0 API Widget class API reference Sisense Pivot documentation Disclaimer: This post outlines a potential custom workaround for a specific use case or provides instructions regarding a specific task. The solution may not work in all scenarios or Sisense versions, so we strongly recommend testing it in your environment before deployment. If you need further assistance with this, please let us know.