I want to add a horizontal scrollbar to the scrolling table

Added a scrolling table to the nodes. The vertical scrollbar is working properly. Now I want to add a horizontal scrollbar. I designed the horizontal scrollbar and the scrolling works, but I am not able to scroll the table rows (content) with the scrollbar. I have included the code and a screenshot of the UI below.

Note: I have made some changes in ScrollingTable.ts for the horizontal scrollbar. Please refer to the modified ScrollingTable.ts below.

  1. UI Interface

  1. ScrollingTable.ts
/*
 *  Copyright 1998-2025 by Northwoods Software Corporation. All Rights Reserved.
 */

/*
 * This is an extension and not part of the main GoJS library.
 * The source code for this is at extensionsJSM/ScrollingTable.ts.
 * Note that the API for this class may change with any version, even point releases.
 * If you intend to use an extension in production, you should copy the code to your own source directory.
 * Extensions can be found in the GoJS kit under the extensions or extensionsJSM folders.
 * See the Extensions intro page (https://gojs.net/latest/intro/extensions.html) for more information.
 */

import * as go from 'gojs';

// A "ScrollingTable" Panel

// This defines an "AutoRepeatButton" Panel,
// which is used by the scrollbar in the "ScrollingTable" Panel.

// It is basically a custom "Button" that automatically repeats its click
// action when the user holds down the mouse.
// The first extra argument may be a number indicating the number of milliseconds
// to wait between calls to the click function.  Default is 50.
// The second extra argument may be a number indicating the number of milliseconds
// to delay before starting calls to the click function.  Default is 500.

// Example:
//   go.GraphObject.build("AutoRepeatButton", {
//       click: (e, button) => doSomething(button.part)
//     }, 150)  // slower than the default 50 milliseconds between calls
//     .add(
//       new go.Shape("Circle", { width: 8, height: 8 })
//     )
if (!go.GraphObject.isBuilderDefined || !go.GraphObject.isBuilderDefined('AutoRepeatButton')) {
  go.GraphObject.defineBuilder('AutoRepeatButton', (args) => {
    const repeat = go.GraphObject.takeBuilderArgument(args, 50, (x) => typeof x === 'number');
    const delay = go.GraphObject.takeBuilderArgument(args, 500, (x) => typeof x === 'number');

    // some internal helper functions for auto-repeating
    function delayClicking(e: go.InputEvent, obj: any) {
      endClicking(e, obj);
      if (obj.click) {
        // wait milliseconds before starting clicks
        obj._timer = setTimeout(() => repeatClicking(e, obj), delay);
      }
    }
    function repeatClicking(e: go.InputEvent, obj: any) {
      if (obj._timer) clearTimeout(obj._timer);
      if (obj.click) {
        obj._timer = setTimeout(() => {
          if (obj.click) {
            obj.click(e, obj);
            repeatClicking(e, obj);
          }
        }, repeat); // milliseconds between clicks
      }
    }
    function endClicking(e: go.InputEvent, obj: any) {
      if (obj._timer) {
        clearTimeout(obj._timer);
        obj._timer = undefined;
      }
    }

    const button = go.GraphObject.build('Button');
    button.actionDown = (e, btn) => delayClicking(e, btn);
    button.actionUp = (e, btn) => endClicking(e, btn);
    button.actionCancel = (e, btn) => endClicking(e, btn);
    return button;
  });
}


// Create a "Table" Panel that supports scrolling.
// This creates a Panel that contains the "Table" Panel whose topIndex is modified plus a scroll bar panel.
// That "Table" Panel is given a name that is given as the optional first argument.
// If not given the name defaults to "TABLE".

// The scroll bar panel is named "SCROLLBAR".
// It has three pieces, the "UP" "AutoRepeatButton", the "THUMB", and the "DOWN" "AutoRepeatButton".
// The scroll bar can be on either side of the "Table" Panel; it defaults to being on the right side.
// The side is controlled by whether the column of the "Table" Panel is 0 (the default) or 2.

// Example use:
//   go.GraphObject.build("ScrollingTable", {
//       // various options
//       // property settings on the whole "ScrollingTable":
//       name: "SCROLLER",
//       desiredSize: new go.Size(. . .),
//       // and on the table being scrolled, by default named "TABLE":
//       "TABLE.itemTemplate": new go.Panel(. . .). . .
//     })
//     .bind("TABLE.itemArray", "someArrayProperty")

// Note that if you have more than one of these in a Part,
// you'll want to make sure each one has a unique name.
go.GraphObject.defineBuilder('ScrollingTable', (args) => {
  const tablename = go.GraphObject.takeBuilderArgument(args, 'TABLE');

  // an internal helper function used by the THUMB for scrolling to a Y-axis point in local coordinates
  function setScrollIndexLocal(bar: go.GraphObject | null, y: number) {
    // may be called with the "SCROLLBAR" panel or any element within it
    while (bar && bar.name !== 'SCROLLBAR') bar = bar.panel;
    if (!(bar instanceof go.Panel)) return;
    const table = bar.panel!.findObject(tablename);
    if (!(table instanceof go.Panel)) return;

    const up = bar.findObject('UP');
    const uph = up ? up.actualBounds.height : 0;

    const down = bar.findObject('DOWN');
    const downh = down ? down.actualBounds.height : 0;

    const tabh = bar.actualBounds.height;
    const idx = Math.round(
      Math.max(0, Math.min(1, (y - uph) / (tabh - uph - downh))) * table.rowCount
    );
    incrTableIndex(bar, idx - table.topIndex);
  }

  // an internal helper function used by the UP and DOWN buttons for relative scrolling
  // the OBJ may be the "SCROLLBAR" panel or any element within it
  function incrTableIndex(obj: go.GraphObject, i: number) {
    const diagram = obj.diagram;
    let table: go.GraphObject | null = obj;
    while (table && table.name !== 'SCROLLBAR') table = table.panel;
    if (table) table = table.panel!.findObject(tablename);
    if (!(table instanceof go.Panel)) return;
    if (i === +Infinity || i === -Infinity) {
      // page up or down
      const tabh = table.actualBounds.height;
      const rowh = table.elt(table.topIndex).actualBounds.height; // assume each row has same height?
      if (i === +Infinity) {
        i = Math.max(1, Math.ceil(tabh / rowh) - 1);
      } else {
        i = -Math.max(1, Math.ceil(tabh / rowh) - 1);
      }
    }
    const maxIdx = getMaxIdx(table);
    let idx = table.topIndex + i;
    if (idx >= maxIdx) idx = maxIdx;
    if (idx < 0) idx = 0;
    if (table.topIndex !== idx) {
      if (diagram !== null) diagram.startTransaction('scroll');
      table.topIndex = idx;
      const node = table.part; // may need to reroute links if the table contains any ports
      if (node instanceof go.Node) node.invalidateConnectedLinks();
      updateScrollBar(table);
      if (diagram !== null) diagram.commitTransaction('scroll');
    }
  }

  // must be passed either the "ScrollingTable" Panel, or the "Table" Panel that holds the rows
  // that are scrolled (i.e. adjusting topIndex), or the "SCROLLBAR" Panel
  // and the amount to scroll
  function scrollTable(table: go.Panel, incr: number) {
    if (!(table instanceof go.Panel) || table.type !== go.Panel.Table) return;
    if (table.part) table.part.ensureBounds();
    if (table.name !== tablename) {
      let tab: go.Panel | null = table;
      while (tab && !(tab as any)._scrollTable) tab = tab.panel;
      if (!tab) return;
      table = tab.findObject(tablename) as go.Panel;
    }

    // the scrollbar is a sibling of the table
    const bar = table.panel!.findObject('SCROLLBAR') as go.Panel;
    if (!bar) return;

    incrTableIndex(bar, incr);
  }

  // must be passed either the "ScrollingTable" Panel, or the "Table" Panel that holds the rows
  // that are scrolled (i.e. adjusting topIndex), or the "SCROLLBAR" Panel
  function updateScrollBar(table: go.Panel) {
    if (!(table instanceof go.Panel) || table.type !== go.Panel.Table) return;
    if (table.part) table.part.ensureBounds();
    if (table.name !== tablename) {
      let tab: go.Panel | null = table;
      while (tab && !(tab as any)._updateScrollBar) tab = tab.panel;
      if (!tab) return;
      table = tab.findObject(tablename) as go.Panel;
    }

    // the scrollbar is a sibling of the table
    const bar = table.panel!.findObject('SCROLLBAR') as go.Panel;
    if (!bar) return;
    const idx = table.topIndex;

    const up = bar.findObject('UP');
    let uph = 0;
    if (up) {
      up.opacity = idx > 0 ? 1.0 : 0.3;
      uph = up.actualBounds.height;
    }

    const down = bar.findObject('DOWN');
    let downh = 0;
    if (down) {
      down.opacity = idx < getMaxIdx(table) ? 1.0 : 0.3;
      downh = down.actualBounds.height;
    }

    const thumb = bar.findObject('THUMB');
    const tabh = bar.actualBounds.height;
    const availh = Math.max(0, tabh - uph - downh);
    if (table.rowCount <= 0) {
      if (thumb) thumb.height = Math.min(availh, 10);
      return;
    }
    const maxIdx = getMaxIdx(table);
    const needed = idx > 0 || idx < maxIdx;
    bar.opacity = needed ? 1.0 : 0.5;
    if (thumb) {
      thumb.height =
        Math.max((getVisibleRows(table) / table.rowCount) * availh,
                 Math.min(availh, 10) - (thumb instanceof go.Shape ? thumb.strokeWidth : 0));
      const spotY = maxIdx === 0 ? 0.5 : idx / maxIdx;
      thumb.alignment = new go.Spot(0.5, Math.min(table.rowCount, spotY, 1), 0, 0);
    }
  }

  function getVisibleRows(table: go.Panel) {
    if (!table) return 0;
    if (table.part) table.part.ensureBounds();
    const tabh = table.actualBounds.height;
    const pad = table.defaultSeparatorPadding as go.Margin;
    const rowh = table.elements.count === 0 || table.elements.count < table.topIndex ? 0 : table.elt(table.topIndex).actualBounds.height + pad.top + pad.bottom; // assume each row has same height?
    if (rowh === 0) return table.rowCount;
    return Math.min(table.rowCount, Math.floor(tabh / rowh));
  }

  function getMaxIdx(table: go.Panel) {
    if (!table) return 0;
    return table.rowCount - getVisibleRows(table);
  }


  //Implemented for horizontal scroll bar

  function getVisibleCols(table: go.Panel) {
    const tabw = table.actualBounds.width;
    const colw = table.measuredBounds.width > 0 ? table.elt(0).actualBounds.width : 0;
    return colw === 0 ? table.width : Math.min(table.measuredBounds.width, Math.floor(tabw / colw));
  }

  function getMaxColIdx(table: go.Panel) {
    return table.measuredBounds.width - getVisibleCols(table);
  }

  function incrTableColumnIndex(obj: go.GraphObject, i: number) {
    const diagram = obj.diagram;
    let table: go.GraphObject | null = obj;
    while (table && table.name !== 'HSCROLLBAR') table = table.panel;
    if (table) table = table.panel!.findObject(tablename);
    if (!(table instanceof go.Panel)) return;

    const maxIdx = getMaxColIdx(table);
    let idx = (table as any).leftIndex || 0;
    idx += i;
    if (idx >= maxIdx) idx = maxIdx;
    if (idx < 0) idx = 0;

    if ((table as any).leftIndex !== idx) {
      if (diagram) diagram.startTransaction('scrollX');
      (table as any).leftIndex = idx;
      updateScrollBarX(table);
      if (diagram) diagram.commitTransaction('scrollX');
    }
  }

  function setScrollIndexLocalX(bar: go.GraphObject | null, x: number) {
    while (bar && bar.name !== 'HSCROLLBAR') bar = bar.panel;
    if (!(bar instanceof go.Panel)) return;
    const table = bar.panel!.findObject(tablename);
    if (!(table instanceof go.Panel)) return;

    const left = bar.findObject('LEFT');
    const lw = left ? left.actualBounds.width : 0;
    const right = bar.findObject('RIGHT');
    const rw = right ? right.actualBounds.width : 0;
    const tabw = bar.actualBounds.width;

    const idx = Math.round(
      Math.max(0, Math.min(1, (x - lw) / (tabw - lw - rw))) * table.actualBounds.width
    );
    incrTableColumnIndex(bar, idx - ((table as any).leftIndex || 0));
  }

  function scrollTableX(table: go.Panel, dx: number) {
    // ensure we're working with the actual table panel
    if (!(table instanceof go.Panel) || table.type !== go.Panel.Table) return;
    if (table.part) table.part.ensureBounds();
    if (table.name !== tablename) {
      let tab: go.Panel | null = table;
      while (tab && !(tab as any)._scrollTableX) tab = tab.panel;
      if (!tab) return;
      table = tab.findObject(tablename) as go.Panel;
    }

    const bar = table.panel!.findObject("HSCROLLBAR") as go.Panel;
    if (!bar) return;

    const maxX = getMaxScrollX(table);
    let pos = (table as any).scrollX || 0;
    pos = Math.max(0, Math.min(maxX, pos + dx));
    (table as any).scrollX = pos;

    updateScrollBarX(table);
  }

  // an internal helper function used by the LEFT and RIGHT buttons for relative horizontal scrolling
  // the OBJ may be the "HSCROLLBAR" panel or any element within it
  function incrTableScrollX(obj: go.GraphObject, dx: number) {
    const diagram = obj.diagram;
    let table: go.GraphObject | null = obj;
    while (table && table.name !== 'HSCROLLBAR') table = table.panel;
    if (table) table = table.panel!.findObject(tablename);
    if (!(table instanceof go.Panel)) return;

    const maxX = getMaxScrollX(table);

    let pos = (table as any).scrollX || 0;

    if (dx === +Infinity || dx === -Infinity) {
      const visible = getVisibleWidth(table);
      dx = dx === +Infinity ? Math.max(visible - 20, 50) : -Math.max(visible - 20, 50);
    }

    pos = Math.max(0, Math.min(maxX, pos + dx));

    if ((table as any).scrollX !== pos) {
      if (diagram) diagram.startTransaction('scrollX');
      (table as any).scrollX = pos;
      updateScrollBarX(table);
      if (diagram) diagram.commitTransaction('scrollX');
    }
  }

  function getMaxItemWidth(table: go.Panel) {
    let maxWidth = 0;

    function checkElt(elt: go.GraphObject) {
      if (elt instanceof go.TextBlock) {
        maxWidth = Math.max(maxWidth, elt.actualBounds.width);
      }
      if (elt instanceof go.Panel) {
        elt.elements.each(child => checkElt(child));
      }
    }

    table.elements.each(row => checkElt(row));
    return maxWidth;
  }




  // Visible width of the table viewport
  function getVisibleWidth(table: go.Panel) {
    return table.actualBounds.width;
  }

// Max scroll distance based on widest row item
  function getMaxScrollX(table: go.Panel) {
    const widest = getMaxItemWidth(table);
    const visible = getVisibleWidth(table);
    return Math.max(0, widest - visible);
  }

// Update horizontal scrollbar based on widest row item
  function updateScrollBarX(table: go.Panel) {
    if (!(table instanceof go.Panel) || table.type !== go.Panel.Table) return;
    if (table.part) table.part.ensureBounds();

    if (table.name !== tablename) {
      let tab: go.Panel | null = table;
      while (tab && !(tab as any)._updateScrollBarX) tab = tab.panel;
      if (!tab) return;
      table = tab.findObject(tablename) as go.Panel;
    }

    const bar = table.panel!.findObject('HSCROLLBAR') as go.Panel;
    if (!bar) return;

    const widest = getMaxItemWidth(table);
    const visible = getVisibleWidth(table);
    const scrollX = (table as any).scrollX || 0;
    const maxX = getMaxScrollX(table);

    bar.visible = widest > visible;

    const left = bar.findObject('LEFT');
    let lefth = 0;
    if (left) {
      left.opacity = scrollX > 0 ? 1.0 : 0.3;
      lefth = left.actualBounds.width;
    }

    const right = bar.findObject('RIGHT');
    let righth = 0;
    if (right) {
      right.opacity = scrollX < maxX ? 1.0 : 0.3;
      righth = right.actualBounds.width;
    }

    const thumb = bar.findObject('HTHUMB');
    const tabw = bar.actualBounds.width;
    const availw = Math.max(0, tabw - lefth - righth);
    const needed = scrollX > 0 || scrollX < maxX;
    bar.opacity = needed ? 1.0 : 0.5;

    if (thumb) {
      const ratio = visible / widest;
      thumb.width = Math.max(
        availw * ratio,
        Math.min(availw, 10) - (thumb instanceof go.Shape ? thumb.strokeWidth : 0)
      );

      // Normalized fraction: 0 at far left, 1 at far right
      const spotX = maxX === 0 ? 0.5 : scrollX / maxX;
      thumb.alignment = new go.Spot(spotX, 0.5, 0, 0);
    }

  }


  return new go.Panel('Table')
    .attach({
      _scrollTable: scrollTable,
      _updateScrollBar: updateScrollBar,
      _updateScrollBarX: updateScrollBarX,
      _scrollTableX: scrollTableX // <-- expose horizontal scroll correctly
    })
    .addColumnDefinition(0, { sizing: go.Sizing.None })
    .addColumnDefinition(1, { stretch: go.Stretch.Horizontal })
    //.addColumnDefinition(2, { sizing: go.Sizing.None })
    .add(
      // this actually holds the item elements
      new go.Panel('Table', {
        name: tablename,
        column: 0,
        stretch: go.Stretch.Fill,
        background: 'whitesmoke',
        defaultAlignment: go.Spot.Top,
        rowSizing: go.Sizing.None
      }),
      // this is the scrollbar
      new go.Panel('Table', {
        name: 'SCROLLBAR',
        column: 1,
        stretch: go.Stretch.Vertical,
        background: '#DDDDDD',
        isActionable: true,
        cursor: 'pointer',
        // clicking in the bar scrolls directly to that point in the list of items
        click: (e, bar) => {
          e.handled = true;
          const local = bar.getLocalPoint(e.documentPoint);
          setScrollIndexLocal(bar, local.y);
        }
      })
        .addRowDefinition(0, { sizing: go.Sizing.None })
        .addRowDefinition(1, { stretch: go.Stretch.Vertical })
        .addRowDefinition(2, { sizing: go.Sizing.None })
        .add(
          // the scroll up button
          go.GraphObject.build<go.Panel>('AutoRepeatButton', {
            name: 'UP',
            row: 0,
            opacity: 0.0,
            'ButtonBorder.figure': 'Rectangle',
            'ButtonBorder.fill': 'transparent',
            'ButtonBorder.strokeWidth': 0,
            'ButtonBorder.spot1': go.Spot.TopLeft,
            'ButtonBorder.spot2': go.Spot.BottomRight,
            _buttonFillOver: 'rgba(0, 0, 0, .25)',
            _buttonStrokeOver: null,
            isActionable: true,
            click: (e, obj) => {
              e.handled = true;
              incrTableIndex(obj, -1);
            }
          })
            .add(
              new go.Shape('TriangleUp', {
                strokeWidth: 0,
                desiredSize: new go.Size(8, 5),
                margin: 1
              })
            ),
          // the scroll thumb, gets all available extra height
          new go.Shape({
            name: 'THUMB',
            row: 1,
            stretch: go.Stretch.Horizontal,
            height: 10,
            margin: new go.Margin(0, 1),
            fill: 'gray',
            stroke: 'transparent',
            alignment: go.Spot.Top,
            alignmentFocus: go.Spot.Top,
            cursor: 'pointer',
            mouseEnter: (e: go.InputEvent, thumb: go.GraphObject) =>
              ((thumb as go.Shape).stroke = 'gray'),
            mouseLeave: (e: go.InputEvent, thumb: go.GraphObject) =>
              ((thumb as go.Shape).stroke = 'transparent'),
            isActionable: true,
            actionMove: (e: go.InputEvent, thumb: go.GraphObject) => {
              const local = thumb.panel!.getLocalPoint(e.documentPoint);
              setScrollIndexLocal(thumb, local.y);
            }
          }),
          // the scroll down button
          go.GraphObject.build<go.Panel>('AutoRepeatButton', {
            name: 'DOWN',
            row: 2,
            opacity: 0.0,
            'ButtonBorder.figure': 'Rectangle',
            'ButtonBorder.fill': 'transparent',
            'ButtonBorder.strokeWidth': 0,
            'ButtonBorder.spot1': go.Spot.TopLeft,
            'ButtonBorder.spot2': go.Spot.BottomRight,
            _buttonFillOver: 'rgba(0, 0, 0, .25)',
            _buttonStrokeOver: null,
            isActionable: true,
            click: (e, obj) => {
              e.handled = true;
              incrTableIndex(obj, +1);
            }
          })
            .add(
              new go.Shape('TriangleDown', {
                strokeWidth: 0,
                desiredSize: new go.Size(8, 5),
                margin: 1
              })
            )
        ),

      // horizontal scrollbar
      new go.Panel('Table', {
        name: 'HSCROLLBAR',
        row: 1, // place below the table
        stretch: go.Stretch.Horizontal,
        background: '#DDDDDD',
        isActionable: true,
        cursor: 'pointer',
        // clicking in the bar scrolls directly to that point in the list of items
        click: (e, bar) => {
          e.handled = true;
          const local = bar.getLocalPoint(e.documentPoint);
          setScrollIndexLocalX(bar, local.x);
        }
      })
        .addColumnDefinition(0, { sizing: go.Sizing.None })
        .addColumnDefinition(1, { stretch: go.Stretch.Horizontal })
        .addColumnDefinition(2, { sizing: go.Sizing.None })
        .add(
        // scroll left button
          go.GraphObject.build<go.Panel>('AutoRepeatButton', {
            name: 'LEFT',
            column: 0,
            'ButtonBorder.figure': 'Rectangle',
            'ButtonBorder.fill': 'transparent',
            'ButtonBorder.strokeWidth': 0,
            'ButtonBorder.spot1': go.Spot.TopLeft,
            'ButtonBorder.spot2': go.Spot.BottomRight,
            _buttonFillOver: 'rgba(0, 0, 0, .25)',
            _buttonStrokeOver: null,
            isActionable: true,
            click: (e, obj) => {
              e.handled = true;
              incrTableScrollX(obj, -50); // scroll left by 50px
            }
          }).add(
            new go.Shape('TriangleLeft',{
              strokeWidth: 0,
              desiredSize: new go.Size(5, 8),
              margin: 1
            })
          ),
          // scroll thumb
          new go.Shape({
            name: 'HTHUMB',
            column: 1,
            height: 10,
            margin: new go.Margin(0, 1),
            fill: 'gray',
            stroke: 'transparent',
            alignment: go.Spot.Top,
            alignmentFocus: go.Spot.Top,
            cursor: 'pointer',
            mouseEnter: (e: go.InputEvent, thumb: go.GraphObject) =>
              ((thumb as go.Shape).stroke = 'gray'),
            mouseLeave: (e: go.InputEvent, thumb: go.GraphObject) =>
              ((thumb as go.Shape).stroke = 'transparent'),
            isActionable: true,
            actionMove: (e: go.InputEvent, thumb: go.GraphObject) => {
              const local = thumb.panel!.getLocalPoint(e.documentPoint);
              setScrollIndexLocalX(thumb, local.x);
            }
          }),

          // scroll right button
          go.GraphObject.build<go.Panel>('AutoRepeatButton', {
            name: 'RIGHT',
            column: 2,
            'ButtonBorder.figure': 'Rectangle',
            'ButtonBorder.fill': 'transparent',
            'ButtonBorder.strokeWidth': 0,
            'ButtonBorder.spot1': go.Spot.TopLeft,
            'ButtonBorder.spot2': go.Spot.BottomRight,
            _buttonFillOver: 'rgba(0, 0, 0, .25)',
            _buttonStrokeOver: null,
            isActionable: true,
            click: (e, obj) => {
              e.handled = true;
              incrTableScrollX(obj, +50); // scroll left by 50px
            }
          }).add(
            new go.Shape('TriangleRight',{
              strokeWidth: 0,
              desiredSize: new go.Size(5, 8),
              margin: 1
            })
          )
        )
);
});



  1. diagram-component.ts

The way that the “ScrollingTable” extension works is that it increments or decrements the Panel.topIndex. Panel | GoJS API

If you were to implement a similar functionality that operates horizontally, it would modify the Panel.leftIndex property. That means it could scroll so that any column could be the first visible column on the left side.

However, that means the user would not be able to scroll to see only a portion of the first visible column. In your case, if your column 1 is very wide because it has a very wide string, the user might still be unable to view the end of the text if the whole “Table” Panel is too narrow.

If you agree with this analysis, I believe you need a scrolling capability that does not use Panel.topIndex or Panel.leftIndex, so that the user can scroll pixel-by-pixel, if needed.

Do you have a sample implementation of a horizontal scrollbar that scrolls pixel‑by‑pixel on a scrolling table?

I’ll work on something like that.

Here’s something that works in 3.1, or in 3.0 if you also get the “AutoRepeatButton” definition from the ScrollingTable extension.

<!DOCTYPE html>
<html>

<head>
  <title>Scrolling any GraphObject Vertically</title>
  <!-- Copyright 1998-2026 by Northwoods Software Corporation. -->
  <meta name="description" content="A GraphObject with a vertical scrollbar">
  <meta name="viewport" content="width=device-width, initial-scale=1">
</head>

<body>
  <div id="myDiagramDiv" style="border: solid 1px black; width:100%; height:600px"></div>

  <script src="https://cdn.jsdelivr.net/npm/gojs/release/go-debug.js"></script>
  <!-- if you are using GoJS 3.0.*, you will need to load the definition of "AutoRepeatButton" -->
  <!-- <script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/extensions/ScrollingTable.js"></script> -->
  <script id="code">

  // Create a scrolling Panel, containing the GraphObject that is given as the required additional argument
  // to a call to GraphObject.build, and both a vertical scroll bar and a horizontal scrollbar.
  //
  // Example use:
  //   go.GraphObject.build("ScrollingBody", {
  //       stretch: go.Stretch.Fill,  // or whatever is desired for this whole scrolling container
  //     },
  //     // Now provide the element to be scrolled.
  //     // This could be any GraphObject, including a Panel.
  //     // it must not stretch vertically or horizontally, but should get its natural width and height
  //     new go.Shape("Triangle", { . . .})
  //  )
  //
  // Note that if you have more than one of these in a Part,
  // you'll want to make sure each one has a unique name.
  go.GraphObject.defineBuilder("ScrollingBody", args => {
    const bodyelement = go.GraphObject.takeBuilderArgument(args, null, x => x instanceof go.GraphObject);
    if (!bodyelement) throw new Error("Building a 'ScrollingBody' needs an argument that is a GraphObject that is to be scrolled");
    const bodyname = bodyelement.name || "_BODY";
    bodyelement.name = bodyname;
    bodyelement.alignment = go.Spot.TopLeft;

    // an internal helper function used by the THUMB for scrolling to a Y-axis point in local coordinates
    function setScrollLocalV(bar, y) {
      // may be called with the "SCROLLBARV" panel or any element within it
      while (bar && bar.name !== "SCROLLBARV") bar = bar.panel;
      if (!bar) return;
      const body = bar.panel.findObject(bodyname);
      if (!body) return;

      const normalHeight = body.actualBounds.height || 1;
      const panelHeight = body.panel.actualBounds.height;

      y *= normalHeight / (panelHeight || 1);

      const up = bar.findObject("UP");
      const uph = up ? up.actualBounds.height : 0;

      incrBodyOffsetV(bar, y - uph + body.alignment.offsetY);
    }

    // an internal helper function for actually performing a scrolling operation
    function incrBodyOffsetV(bar, i) {
      // may be called with the "SCROLLBARV" panel or any element within it
      while (bar && bar.name !== "SCROLLBARV") bar = bar.panel;
      if (!bar) return;
      let scroller = bar.panel;
      while (scroller && !scroller._updateScrollBar) scroller = scroller.panel;
      if (!scroller) return;
      const body = scroller.findObject(bodyname);
      // if (i === +Infinity) {  // page up or down
      //   i += bar.actualBounds.height - 10;
      // } else if (i === -Infinity) {
      //   i -= bar.actualBounds.height - 10;
      // }

      const normalHeight = body.actualBounds.height || 1;
      const panelHeight = bar.actualBounds.height;

      let offy = -body.alignment.offsetY + i;
      if (offy < 0) offy = 0;
      else if (offy >= normalHeight - panelHeight) offy = Math.max(0, normalHeight - panelHeight);
      const diagram = scroller.diagram;
      if (-body.alignment.offsetY !== offy) {
        if (diagram !== null) diagram.startTransaction("scroll");
        const align = body.alignment.isSpot() ? body.alignment : go.Spot.TopLeft;
        body.alignment = new go.Spot(align.x, 0, align.offsetX, -offy);
        updateScrollBarV(scroller);
        if (diagram !== null) diagram.commitTransaction("scroll");
      }
    }

    function updateScrollBarV(scroller) {
      while (scroller && !scroller._updateScrollBar) scroller = scroller.panel;
      if (!scroller) return;
      const body = scroller.findObject(bodyname);
      if (!body) return;
      const bar = scroller.findObject("SCROLLBARV");  // the scrollbar is a sibling of the scrolled object
      if (!bar) return;

      const normalHeight = body.actualBounds.height || 1;
      const panelHeight = bar.actualBounds.height;
      const offy = -body.alignment.offsetY;
      const needed = offy > 0 || normalHeight > panelHeight;

      bar.opacity = needed ? 1.0 : 0.0;

      if (needed) {
        const up = bar.findObject("UP");
        let uph = 0;
        if (up) {
          up.opacity = (offy > 0) ? 1.0 : 0.3;
          uph = up.actualBounds.height;
        }

        const down = bar.findObject("DOWN");
        let downh = 0;
        if (down) {
          down.opacity = (offy < normalHeight - panelHeight) ? 1.0 : 0.3;
          downh = down.actualBounds.height;
        }

        const thumb = bar.findObject("THUMBV");
        if (thumb) {
          const tabh = bar.actualBounds.height;
          const availh = Math.max(0, (tabh - uph - downh));
          thumb.opacity = normalHeight > panelHeight ? 1.0 : 0.0;
          if (thumb.opacity > 0.0) {
            thumb.height = Math.max(0,
              Math.max((panelHeight / normalHeight) * availh,
                       Math.min(availh, 10))
              - (thumb instanceof go.Shape ? thumb.strokeWidth : 0));
            thumb.alignment = new go.Spot(0.5, Math.max(0, Math.min(offy / (normalHeight - panelHeight), 1)), 0, 0);
          } else {
            const align = body.alignment.isSpot() ? body.alignment : go.Spot.TopLeft;
            body.alignment = new go.Spot(align.x, 0, align.offsetX, 0);
            bar.opacity = 0.0;
          }
        }
      }
    }

    // an internal helper function used by the THUMB for scrolling to a Y-axis point in local coordinates
    function setScrollLocalH(bar, x) {
      // may be called with the "SCROLLBARH" panel or any element within it
      while (bar && bar.name !== "SCROLLBARH") bar = bar.panel;
      if (!bar) return;
      const body = bar.panel.findObject(bodyname);
      if (!body) return;

      const normalWidth = body.actualBounds.width || 1;
      const panelWidth = body.panel.actualBounds.width;

      x *= normalWidth / (panelWidth || 1);

      const left = bar.findObject("LEFT");
      const leftw = left ? left.actualBounds.width : 0;

      incrBodyOffsetH(bar, x - leftw + body.alignment.offsetX);
    }

    // an internal helper function for actually performing a scrolling operation
    function incrBodyOffsetH(bar, i) {
      // may be called with the "SCROLLBARH" panel or any element within it
      while (bar && bar.name !== "SCROLLBARH") bar = bar.panel;
      if (!bar) return;
      let scroller = bar.panel;
      while (scroller && !scroller._updateScrollBar) scroller = scroller.panel;
      if (!scroller) return;
      const body = scroller.findObject(bodyname);
      // if (i === +Infinity) {  // page left or right
      //   i += bar.actualBounds.width - 10;
      // } else if (i === -Infinity) {
      //   i -= bar.actualBounds.width - 10;
      // }

      const normalWidth = body.actualBounds.width || 1;
      const panelWidth = bar.actualBounds.width;

      let offx = -body.alignment.offsetX + i;
      if (offx < 0) offx = 0;
      else if (offx >= normalWidth - panelWidth) offx = Math.max(0, normalWidth - panelWidth);
      const diagram = scroller.diagram;
      if (-body.alignment.offsetX !== offx) {
        if (diagram !== null) diagram.startTransaction("scroll");
        const align = body.alignment.isSpot() ? body.alignment : go.Spot.TopLeft;
        body.alignment = new go.Spot(0, align.y, -offx, align.offsetY);
        updateScrollBarH(scroller);
        if (diagram !== null) diagram.commitTransaction("scroll");
      }
    }

    function updateScrollBarH(scroller) {
      while (scroller && !scroller._updateScrollBar) scroller = scroller.panel;
      if (!scroller) return;
      const body = scroller.findObject(bodyname);
      if (!body) return;
      const bar = scroller.findObject("SCROLLBARH");  // the scrollbar is a sibling of the scrolled object
      if (!bar) return;

      const normalWidth = body.actualBounds.width || 1;
      const panelWidth = bar.actualBounds.width;
      const offx = -body.alignment.offsetX;
      const needed = offx > 0 || normalWidth > panelWidth;

      bar.opacity = needed ? 1.0 : 0.0;

      if (needed) {
        const left = bar.findObject("LEFT");
        let leftw = 0;
        if (left) {
          left.opacity = (offx > 0) ? 1.0 : 0.3;
          leftw = left.actualBounds.width;
        }

        const right = bar.findObject("RIGHT");
        let rightw = 0;
        if (right) {
          right.opacity = (offx < normalWidth - panelWidth) ? 1.0 : 0.3;
          rightw = right.actualBounds.width;
        }

        const thumb = bar.findObject("THUMBH");
        if (thumb) {
          const tabw = bar.actualBounds.width;
          const availw = Math.max(0, (tabw - leftw - rightw));
          thumb.opacity = normalWidth > panelWidth ? 1.0 : 0.0;
          if (thumb.opacity > 0.0) {
            thumb.width = Math.max(0,
              Math.max((panelWidth / normalWidth) * availw,
                       Math.min(availw, 10))
              - (thumb instanceof go.Shape ? thumb.strokeWidth : 0));
            thumb.alignment = new go.Spot(Math.max(0, Math.min(offx / (normalWidth - panelWidth), 1)), 0.5, 0, 0);
          } else {
            const align = body.alignment.isSpot() ? body.alignment : go.Spot.TopLeft;
            body.alignment = new go.Spot(0, align.y, 0, align.offsetY);
            bar.opacity = 0.0;
          }
        }
      }
    }

  return new go.Panel("Table")
    .attach({ // in case external code wants to update the scrollbar
      _updateScrollBar: panel => { updateScrollBarV(panel); updateScrollBarH(panel); }  // be able to call this internal function externally
    })

    // this holds the GraphObject to be scrolled
    .add(
      new go.Panel("Auto", { stretch: go.Stretch.Fill, alignment: go.Spot.TopLeft })
        .add(new go.Shape({ strokeWidth: 0, opacity: 0 }))
        .add(bodyelement)
    )

    // this is the vertical scrollbar
    .addColumnDefinition(1, { sizing: go.Sizing.None })
    .add(
      new go.Panel("Table", {
          name: "SCROLLBARV", column: 1,
          stretch: go.Stretch.Vertical, background: "#DDDDDD",
          mouseEnter: function (e, bar) { updateScrollBarV(bar.panel); },
          // clicking in the bar scrolls directly to that point in the list of items
          click: function (e, bar) {
              e.handled = true;
              const local = bar.getLocalPoint(e.documentPoint);
              setScrollLocalV(bar, local.y);
            }
        })
        .addRowDefinition(0, { sizing: go.Sizing.None })
        .addRowDefinition(1, { stretch: go.Stretch.Vertical })
        .addRowDefinition(2, { sizing: go.Sizing.None })
        .add(
          // the scroll up button
          go.GraphObject.build("AutoRepeatButton", {
              name: "UP", row: 0, opacity: 0,
              'ButtonBorder.stroke': null,
              click: function (e, obj) { e.handled = true; incrBodyOffsetV(obj, -10); }
            })
            .add(
              new go.Shape("TriangleUp", { stroke: null, desiredSize: new go.Size(6, 6) })
            ),

          // the scroll thumb, gets all available extra height
          new go.Shape({
              name: "THUMBV", row: 1,
              stretch: go.Stretch.Horizontal, height: 10,
              margin: new go.Margin(0, 2),
              fill: "gray", stroke: "transparent",
              alignment: go.Spot.Top, alignmentFocus: go.Spot.Top,
              mouseEnter: function (e, thumb) { thumb.stroke = "gray"; },
              mouseLeave: function (e, thumb) { thumb.stroke = "transparent"; },
              isActionable: true,
              actionMove: function (e, thumb) {
                const local = thumb.panel.getLocalPoint(e.documentPoint);
                setScrollLocalV(thumb, local.y);
              }
            }),

          // the scroll down button
          go.GraphObject.build("AutoRepeatButton", {
              name: "DOWN", row: 2, opacity: 0,
              'ButtonBorder.stroke': null,
              click: function (e, obj) { e.handled = true; incrBodyOffsetV(obj, +10); }
            })
            .add(
              new go.Shape("TriangleDown", { stroke: null, desiredSize: new go.Size(6, 6) })
            )
        )
    )

    // this is the horizontal scrollbar
    .addRowDefinition(1, { sizing: go.Sizing.None })
    .add(
      new go.Panel("Table", {
          name: "SCROLLBARH", row: 1,
          stretch: go.Stretch.Horizontal, background: "#DDDDDD",
          mouseEnter: function (e, bar) { updateScrollBarH(bar.panel); },
          // clicking in the bar scrolls directly to that point in the list of items
          click: function (e, bar) {
              e.handled = true;
              const local = bar.getLocalPoint(e.documentPoint);
              setScrollLocalH(bar, local.x);
            }
        })
        .addColumnDefinition(0, { sizing: go.Sizing.None })
        .addColumnDefinition(1, { stretch: go.Stretch.Horizontal })
        .addColumnDefinition(2, { sizing: go.Sizing.None })
        .add(
          // the scroll up button
          go.GraphObject.build("AutoRepeatButton", {
              name: "LEFT", column: 0, opacity: 0,
              'ButtonBorder.stroke': null,
              click: function (e, obj) { e.handled = true; incrBodyOffsetH(obj, -10); }
            })
            .add(
              new go.Shape("TriangleLeft", { stroke: null, desiredSize: new go.Size(6, 6) })
            ),

          // the scroll thumb, gets all available extra height
          new go.Shape({
              name: "THUMBH", column: 1,
              stretch: go.Stretch.Vertical, width: 10,
              margin: new go.Margin(2, 0),
              fill: "gray", stroke: "transparent",
              alignment: go.Spot.Left, alignmentFocus: go.Spot.Left,
              mouseEnter: function (e, thumb) { thumb.stroke = "gray"; },
              mouseLeave: function (e, thumb) { thumb.stroke = "transparent"; },
              isActionable: true,
              actionMove: function (e, thumb) {
                const local = thumb.panel.getLocalPoint(e.documentPoint);
                setScrollLocalH(thumb, local.x);
              }
            }),

          // the scroll down button
          go.GraphObject.build("AutoRepeatButton", {
              name: "RIGHT", column: 2, opacity: 0,
              'ButtonBorder.stroke': null,
              click: function (e, obj) { e.handled = true; incrBodyOffsetH(obj, +10); }
            })
            .add(
              new go.Shape("TriangleRight", { stroke: null, desiredSize: new go.Size(6, 6) })
            )
        )
    )
});  // end ScrollingBody


myDiagram =
  new go.Diagram("myDiagramDiv", {
    "InitialLayoutCompleted": e => e.diagram.nodes.each(updateScrollBarForNode),
    "PartResized": e => updateScrollBarForNode(e.subject.part),
    "undoManager.isEnabled": true
  });

myDiagram.nodeTemplate =
  new go.Node("Vertical", {
    selectionObjectName: "RESIZABLE",
    resizable: true, resizeObjectName: "RESIZABLE",
  })
  .add(
    new go.TextBlock({ font: "bold 10pt sans-serif" })
      .bind("text"),
    new go.Panel("Auto", { name: "RESIZABLE", width: 100, height: 100, minSize: new go.Size(50, 50) })
      .bindTwoWay("desiredSize", "size", go.Size.parse, go.Size.stringify)
      .add(
        new go.Shape({ fill: "whitesmoke", stroke: "lightgray" }),
        go.GraphObject.build("ScrollingBody", {
            name: "SCROLLER",
            margin: 1,
            stretch: go.Stretch.Fill, alignment: go.Spot.TopLeft
          },
          // this is the element to be scrolled
          new go.Panel("Vertical")
            .add(
              new go.TextBlock("first"),
              new go.TextBlock("second second second"),
              new go.TextBlock("third"),
              new go.TextBlock("fourth"),
              new go.TextBlock("fifth"),
              new go.TextBlock("sixth"),
              new go.TextBlock("seventh seventh seventh seventh"),
              new go.TextBlock("eighth"),
              new go.TextBlock("ninth"),
              new go.TextBlock("tenth"),
            )
        )
      )
  );
myDiagram.nodeTemplate.findObject("SCROLLER")


function updateScrollBarForNode(node) {
  const scroller = node.findObject("SCROLLER");
  if (scroller) scroller._updateScrollBar(scroller);
}

myDiagram.model = new go.GraphLinksModel(
  [
    { key: 1, text: "Alpha", color: "lightblue" },
    { key: 2, text: "Beta", color: "orange", size: "230 80" },
    { key: 3, text: "Gamma", color: "lightgreen", size: "50 50" },
    { key: 4, text: "Delta", color: "pink", size: "150 300" }
  ]);
  </script>
</body>

</html>