a pannable canvas

drag to pan, scroll-wheel to zoom, anchored under the cursor

by the end of this guide you will have a full-screen canvas you can drag to pan and scroll to zoom. the zoom is anchored under the cursor so the world point under it stays fixed, and the scale is clamped to a min and max so you cannot zoom out to nothing or in to infinity. this is the foundation for a node graph, a map, or any zoomable diagram you build on top.

the viewport helper

goo ships Goo.Viewport, a plain C# object that owns pan and zoom state and keeps the anchor math correct.

create Code/Canvas/CanvasUI.cs and declare the class with a Viewport field:

using Goo;
using Goo.Components;
using Sandbox;
using Sandbox.UI;
using PanelTransform = Goo.PanelTransform;

namespace Sandbox;

public class CanvasUI : GooPanel<Container>
{
    readonly Viewport _view = new() { MinZoom = 0.25f, MaxZoom = 4f };
    Vector2 _lastMouse;

    protected override void OnEnabled()
    {
        base.OnEnabled();
        Panel.Style.Width  = Length.Percent( 100 );
        Panel.Style.Height = Length.Percent( 100 );
    }
}

MinZoom and MaxZoom are init-only clamps. _lastMouse tracks the cursor position so you can compute drag deltas and pass it to ZoomAt.

the outer shell

add a Build() that returns a full-screen container with Overflow.Hidden. clipping is what makes pan feel like a viewport rather than a panel that slides around on top of the page.

protected override Container Build() => new Container
{
    Width           = Length.Percent( 100 ),
    Height          = Length.Percent( 100 ),
    BackgroundColor = Color.Black.WithAlpha( 0.85f ),
    Overflow        = OverflowMode.Hidden,
    Children =
    {
        World(),
    },
};

the transformed world

add the World() helper. this container holds the canvas content and is the only thing that moves. TransformOriginX and TransformOriginY are both 0 so the scale pivot matches the top-left origin Viewport math assumes (see panel transform).

Container World()
{
    var world = new Container
    {
        Key              = "world",
        Position         = PositionMode.Absolute,
        Left             = 0,
        Top              = 0,
        TransformOriginX = 0,
        TransformOriginY = 0,
        Transform = PanelTransform
            .Scale( _view.Zoom )
            .Translate( Px.Of( _view.Pan.x ), Px.Of( _view.Pan.y ) ),
    };

    // placeholder content: a few colored cards at world coordinates
    world.Children.Add( Card( "card-a", 100, 100, Color.Parse( "#3c82f6" ), "a (100, 100)" ) );
    world.Children.Add( Card( "card-b", 520, 280, Color.Parse( "#22c55e" ), "b (520, 280)" ) );
    world.Children.Add( Card( "card-c", 940, 580, Color.Parse( "#f97316" ), "c (940, 580)" ) );

    return world;
}

static Container Card( string key, float x, float y, Color color, string label ) => new Container
{
    Key             = key,
    Position        = PositionMode.Absolute,
    Left            = x,
    Top             = y,
    Padding         = 14,
    BorderRadius    = 10,
    BackgroundColor = color,
    Children        = { new Text( label ) { FontColor = Color.White } },
};

Key on the world container lets goo recognize it across rebuilds so the element is patched rather than torn down and re-created on every mouse-move.

wiring pan

pan updates on OnMouseMove. you compute the delta from the previous position and call PanBy only when the mouse button is held (e.Target.HasActive).

update Build() to add the event:

protected override Container Build() => new Container
{
    Width           = Length.Percent( 100 ),
    Height          = Length.Percent( 100 ),
    BackgroundColor = Color.Black.WithAlpha( 0.85f ),
    Overflow        = OverflowMode.Hidden,
    OnMouseMove = e =>
    {
        var delta = e.LocalPosition - _lastMouse;
        _lastMouse = e.LocalPosition;
        if ( e.Target.HasActive ) { _view.PanBy( delta ); Rebuild(); }
    },
    Children =
    {
        World(),
    },
};

press play, hold the left button, and drag. the cards move with the cursor.

wiring zoom

add OnMouseWheel to the same container. _lastMouse already holds the cursor position from the last OnMouseMove, so you can pass it straight to ZoomAt. a positive scroll.y zooms in (factor 1.1), negative zooms out (factor 1 / 1.1). Viewport clamps the result to [MinZoom, MaxZoom] automatically.

protected override Container Build() => new Container
{
    Width           = Length.Percent( 100 ),
    Height          = Length.Percent( 100 ),
    BackgroundColor = Color.Black.WithAlpha( 0.85f ),
    Overflow        = OverflowMode.Hidden,
    OnMouseMove = e =>
    {
        var delta = e.LocalPosition - _lastMouse;
        _lastMouse = e.LocalPosition;
        if ( e.Target.HasActive ) { _view.PanBy( delta ); Rebuild(); }
    },
    OnMouseWheel = scroll =>
    {
        _view.ZoomAt( _lastMouse, scroll.y > 0 ? 1.1f : 1f / 1.1f );
        Rebuild();
    },
    Children =
    {
        World(),
    },
};

scroll over card-b and the card stays fixed under the cursor.

what just happened

there are three layers, each with one job:

because Build() is a pure function of _view.Zoom and _view.Pan, every rebuild from any input produces the correct frame. there is no incremental state to get out of sync.

see also