embedding a web page¶
load a URL inside rounded clipped corners, pause it, hide it without killing the panel, and switch between bookmarked pages
by the end of this guide you will have a panel that renders a live web page inside a rounded clip wrapper, a button that pauses and resumes the page, and a second button that unmounts and remounts the whole webview so the frame is gone when you do not need it. from there you will add a row of bookmarks that swap which page is loaded, and a brief loading overlay that appears while a swap is in flight.
the webpanel blob¶
goo wraps the engine Chromium webview in a blob called WebPanel. you give it a Url and a size; it renders the page as part of your layout tree.
create Code/MyUI/WebEmbedUI.cs:
using Goo;
using Sandbox.UI;
namespace Sandbox;
public class WebEmbedUI : GooPanel<Container>
{
protected override Container Build() => new Container
{
Padding = 24,
BackgroundColor = Color.Black.WithAlpha( 0.7f ),
BorderRadius = 16,
Children =
{
new Goo.WebPanel
{
Url = "https://google.com/",
Width = 640,
Height = 420,
},
},
};
}
press play and mount WebEmbedUI on a screen panel. the page loads in place, laid out like any other blob.
rounding the corners¶
WebPanel does expose BorderRadius, but setting it has no visual effect: the webview owns its own paint surface and does not clip to it. WebPanel does not expose Overflow at all. corner clipping has to come from a wrapping Container instead. set BorderRadius and Overflow = OverflowMode.Hidden on the wrapper and the corners clip the page correctly.
protected override Container Build() => new Container
{
Padding = 24,
BackgroundColor = Color.Black.WithAlpha( 0.7f ),
BorderRadius = 16,
Children =
{
new Container
{
Key = "web-wrap",
BorderRadius = 12,
Overflow = OverflowMode.Hidden,
Width = 640,
Height = 420,
Children =
{
new Goo.WebPanel
{
Url = "https://google.com/",
Width = Length.Percent( 100 ),
Height = Length.Percent( 100 ),
},
},
},
},
};
the inner wrapper at BorderRadius = 12 plus Overflow = OverflowMode.Hidden does the actual clipping work on the webview.
pausing the page¶
a live webview ticks JavaScript, repaints, and runs media even when nothing is visible. Paused = true throttles all of that. the approach is a _paused field on the class, a button that flips it, and the field fed directly into Paused on each build. flipping the field inside OnClick is enough: goo rebuilds the panel automatically once any event handler returns (AutoRebuildOnEvents, on by default), so the handler does not need to call Rebuild() itself.
public class WebEmbedUI : GooPanel<Container>
{
bool _paused;
protected override Container Build() => new Container
{
Padding = 24,
BackgroundColor = Color.Black.WithAlpha( 0.7f ),
BorderRadius = 16,
Gap = 12,
Children =
{
new Container
{
Children =
{
new Container
{
PaddingTop = 6,
PaddingBottom = 6,
PaddingLeft = 14,
PaddingRight = 14,
BackgroundColor = Color.FromRgb( 0x2563eb ),
HoverBackgroundColor = Color.FromRgb( 0x1d4ed8 ),
BorderRadius = 6,
OnClick = e => _paused = !_paused,
Children = { new Text( _paused ? "resume" : "pause" ) },
},
},
},
new Container
{
Key = "web-wrap",
BorderRadius = 12,
Overflow = OverflowMode.Hidden,
Width = 640,
Height = 420,
Children =
{
new Goo.WebPanel
{
Url = "https://google.com/",
Paused = _paused,
Width = Length.Percent( 100 ),
Height = Length.Percent( 100 ),
},
},
},
},
};
}
click pause and the page goes quiet; click resume and it picks back up.
hiding and showing with unmount¶
hiding the wrapper with Opacity = 0 leaves the webview running silently. to actually stop it consuming resources, pull it out of the tree entirely. a _visible field controls whether the wrapper slot has a child. Children.Add<T>(in T? child) skips a null silently, so a nullable ternary directly in the Children initializer is enough: when _visible is false the slot gets null and the webview is gone; when it flips back the reconciler creates a fresh one.
here is the complete class with both controls:
public class WebEmbedUI : GooPanel<Container>
{
bool _paused;
bool _visible = true;
protected override Container Build() => new Container
{
Padding = 24,
BackgroundColor = Color.Black.WithAlpha( 0.7f ),
BorderRadius = 16,
Gap = 12,
Children =
{
new Container
{
FlexDirection = FlexDirection.Row,
Gap = 8,
Children =
{
new Container
{
PaddingTop = 6,
PaddingBottom = 6,
PaddingLeft = 14,
PaddingRight = 14,
BackgroundColor = Color.FromRgb( 0x2563eb ),
HoverBackgroundColor = Color.FromRgb( 0x1d4ed8 ),
BorderRadius = 6,
OnClick = e => _paused = !_paused,
Children = { new Text( _paused ? "resume" : "pause" ) },
},
new Container
{
PaddingTop = 6,
PaddingBottom = 6,
PaddingLeft = 14,
PaddingRight = 14,
BackgroundColor = Color.FromRgb( 0x4b5563 ),
HoverBackgroundColor = Color.FromRgb( 0x374151 ),
BorderRadius = 6,
OnClick = e => _visible = !_visible,
Children = { new Text( _visible ? "hide" : "show" ) },
},
},
},
new Container
{
Key = "web-slot",
Children =
{
_visible
? new Container
{
Key = "web-wrap",
BorderRadius = 12,
Overflow = OverflowMode.Hidden,
Width = 640,
Height = 420,
Children =
{
new Goo.WebPanel
{
Url = "https://google.com/",
Paused = _paused,
Width = Length.Percent( 100 ),
Height = Length.Percent( 100 ),
},
},
}
: null,
},
},
},
};
}
the Key = "web-slot" container is always present so the surrounding layout does not shift; only its child appears and disappears. Key is what lets the reconciler match this container to itself across rebuilds instead of tearing it down and rebuilding a new one, so the slot stays put even while its contents come and go.
switching between bookmarks¶
one hardcoded Url is a demo, not a browser. a real embed usually needs to swap between a handful of known pages. add a small bookmark list and a row of buttons that pick the active one:
static readonly (string Label, string Url)[] Bookmarks =
{
( "search", "https://google.com/" ),
( "news", "https://apnews.com/" ),
( "docs", "https://developer.mozilla.org/" ),
};
_bookmark is the active index. it only ever changes inside a click handler, so it stays a plain field, the same pattern _paused and _visible already use. the row itself is the one part of the tree that is data-driven rather than literal, so it is the one place Each.Of fits: drop it directly into the Children initializer and it runs the loop in place, auto-keyed like Children.AddRange.
int _bookmark;
new Container
{
FlexDirection = FlexDirection.Row,
Gap = 8,
Children =
{
Each.Of( Bookmarks, ( i, b ) => new Container
{
PaddingTop = 6,
PaddingBottom = 6,
PaddingLeft = 14,
PaddingRight = 14,
BackgroundColor = i == _bookmark ? Color.FromRgb( 0x2563eb ) : Color.FromRgb( 0x4b5563 ),
HoverBackgroundColor = Color.FromRgb( 0x1d4ed8 ),
BorderRadius = 6,
OnClick = e => _bookmark = i,
Children = { new Text( b.Label ) },
} ),
},
}
feed the active bookmark's Url into the WebPanel instead of the literal string:
new Goo.WebPanel
{
Url = Bookmarks[_bookmark].Url,
Paused = _paused,
Width = Length.Percent( 100 ),
Height = Length.Percent( 100 ),
},
click a different bookmark and the reconciler diffs the changed Url onto the same mounted WebPanel: no Key change and no remount, the webview just navigates. that is a different code path from the hide button above, which drops the panel out of the tree entirely and forces a fresh one on the way back in.
layering a loading overlay¶
a page swap is not instant. show a short overlay while one is in flight, dimming the page and centering a "loading" caption on top of it. that overlay has to sit on top of the webview rather than push it aside: normal flex layout places siblings next to each other, not on top of each other. Layout.Stack() returns a Container with Position = Relative, and Layout.Layer(child) returns that child with Position = Absolute, Top 0, Left 0; a Relative parent around Absolute children is what makes them overlap instead of laying out side by side. build the wrapper with Layout.Stack() and wrap each overlapping child with Layout.Layer(child).
static Container LoadingOverlay() => new()
{
Width = Length.Percent( 100 ),
Height = Length.Percent( 100 ),
BackgroundColor = Color.Black.WithAlpha( 0.5f ),
JustifyContent = Justify.Center,
AlignItems = Align.Center,
Children = { new Text( "loading" ) { FontColor = Color.White } },
};
the timing behind it needs its own field. _paused, _visible, and _bookmark above all change only inside a click handler, so a plain field is enough there: the handler triggers a rebuild once it returns. a loading timer is different. it counts down every frame inside Tick, which is not an event handler, so nothing rebuilds the panel automatically when it changes. that is exactly the case State<T> covers: wrap the field in Track(initial) and writing .Value marks the panel dirty for you, with an equal write costing nothing.
State<float> _loadTimer;
protected override void OnEnabled()
{
base.OnEnabled();
_loadTimer = Track( 0f );
}
protected override bool Tick( float dt )
{
_loadTimer.Value = _loadTimer.Value > dt ? _loadTimer.Value - dt : 0f;
return false;
}
Tick returns false every frame here, not because nothing is happening, but because the rebuild is not Tick's job in this case: _loadTimer.Value's own setter already calls Rebuild() whenever the countdown actually changes, and stops doing so the instant it settles at 0f, since an equal write is a no-op. bump the timer when a bookmark click starts a swap:
OnClick = e => { _bookmark = i; _loadTimer.Value = 0.6f; },
then build the wrapper as a stack and add the overlay layer only while the timer is running. this construction takes a few steps, so it is its own helper, called from inside Build(): only Container carries pooled child storage, alive only while Build() is running, so any function that builds one has to run while Build() is executing:
Container BuildWebHost()
{
var host = Layout.Stack() with
{
Key = "web-wrap",
BorderRadius = 12,
Overflow = OverflowMode.Hidden,
Width = 640,
Height = 420,
};
host.Children.Add( Layout.Layer( new Container
{
Width = Length.Percent( 100 ),
Height = Length.Percent( 100 ),
Children =
{
new Goo.WebPanel
{
Url = Bookmarks[_bookmark].Url,
Paused = _paused,
Width = Length.Percent( 100 ),
Height = Length.Percent( 100 ),
},
},
} ) );
if ( _loadTimer.Value > 0f )
host.Children.Add( Layout.Layer( LoadingOverlay() ) );
return host;
}
the slot from the hiding-and-showing section above still gates the whole thing with the same nullable ternary, now calling the helper instead of inlining a literal wrapper:
new Container
{
Key = "web-slot",
Children = { _visible ? BuildWebHost() : null },
}
WebPanel has no load-finished event to hook, so the 0.6 second window is a fixed guess, not a real signal from the page. treat it as a placeholder transition, not a loading indicator you can trust for a slow page.
here is the complete class with all four behaviors:
using Goo;
using Sandbox.UI;
namespace Sandbox;
public class WebEmbedUI : GooPanel<Container>
{
static readonly (string Label, string Url)[] Bookmarks =
{
( "search", "https://google.com/" ),
( "news", "https://apnews.com/" ),
( "docs", "https://developer.mozilla.org/" ),
};
bool _paused;
bool _visible = true;
int _bookmark;
State<float> _loadTimer;
protected override void OnEnabled()
{
base.OnEnabled();
_loadTimer = Track( 0f );
}
protected override bool Tick( float dt )
{
_loadTimer.Value = _loadTimer.Value > dt ? _loadTimer.Value - dt : 0f;
return false;
}
protected override Container Build()
{
var controls = new Container
{
FlexDirection = FlexDirection.Row,
Gap = 8,
Children =
{
new Container
{
PaddingTop = 6,
PaddingBottom = 6,
PaddingLeft = 14,
PaddingRight = 14,
BackgroundColor = Color.FromRgb( 0x2563eb ),
HoverBackgroundColor = Color.FromRgb( 0x1d4ed8 ),
BorderRadius = 6,
OnClick = e => _paused = !_paused,
Children = { new Text( _paused ? "resume" : "pause" ) },
},
new Container
{
PaddingTop = 6,
PaddingBottom = 6,
PaddingLeft = 14,
PaddingRight = 14,
BackgroundColor = Color.FromRgb( 0x4b5563 ),
HoverBackgroundColor = Color.FromRgb( 0x374151 ),
BorderRadius = 6,
OnClick = e => _visible = !_visible,
Children = { new Text( _visible ? "hide" : "show" ) },
},
},
};
var bookmarks = new Container
{
FlexDirection = FlexDirection.Row,
Gap = 8,
Children =
{
Each.Of( Bookmarks, ( i, b ) => new Container
{
PaddingTop = 6,
PaddingBottom = 6,
PaddingLeft = 14,
PaddingRight = 14,
BackgroundColor = i == _bookmark ? Color.FromRgb( 0x2563eb ) : Color.FromRgb( 0x4b5563 ),
HoverBackgroundColor = Color.FromRgb( 0x1d4ed8 ),
BorderRadius = 6,
OnClick = e => { _bookmark = i; _loadTimer.Value = 0.6f; },
Children = { new Text( b.Label ) },
} ),
},
};
return new Container
{
Padding = 24,
BackgroundColor = Color.Black.WithAlpha( 0.7f ),
BorderRadius = 16,
Gap = 12,
Children =
{
controls,
bookmarks,
new Container
{
Key = "web-slot",
Children = { _visible ? BuildWebHost() : null },
},
},
};
}
Container BuildWebHost()
{
var host = Layout.Stack() with
{
Key = "web-wrap",
BorderRadius = 12,
Overflow = OverflowMode.Hidden,
Width = 640,
Height = 420,
};
host.Children.Add( Layout.Layer( new Container
{
Width = Length.Percent( 100 ),
Height = Length.Percent( 100 ),
Children =
{
new Goo.WebPanel
{
Url = Bookmarks[_bookmark].Url,
Paused = _paused,
Width = Length.Percent( 100 ),
Height = Length.Percent( 100 ),
},
},
} ) );
if ( _loadTimer.Value > 0f )
host.Children.Add( Layout.Layer( LoadingOverlay() ) );
return host;
}
static Container LoadingOverlay() => new()
{
Width = Length.Percent( 100 ),
Height = Length.Percent( 100 ),
BackgroundColor = Color.Black.WithAlpha( 0.5f ),
JustifyContent = Justify.Center,
AlignItems = Align.Center,
Children = { new Text( "loading" ) { FontColor = Color.White } },
};
}
key both stacked layers (the page and the overlay) - the overlay toggling inside an unkeyed list makes the reconciler log a keyless-length-change warning.
what just happened¶
five ideas were layered, each building on the last:
WebPanelwith aUrlis enough to render a live page. size it the same way you size any blob.WebPaneldoes not clip its own corners, even though it exposesBorderRadius: the webview paints its own surface underneath the style facade. aContainerwithBorderRadiusandOverflow = OverflowMode.Hiddenwraps it and does the clipping.Paused = truethrottles the webview without removing it. if you want to stop it completely, omit it from the tree by gating on a_visiblefield. both are valid; use whichever matches the use case.- a row of bookmarks is a data-driven list, so it is built with
Each.Ofinstead of being written out by hand. switching the active one changesUrlon the same mounted panel; the page navigates instead of remounting. - a loading overlay needs to sit on top of the page, not beside it, so the wrapper is built with
Layout.Stack()/Layout.Layer(). its timer counts down insideTick, outside any click handler, which is exactly the caseState<T>exists for: writing.Valueinvalidates the panel for you, and stops costing anything once the value settles.
the probe this guide is based on lives in Code/Demo/DocsProbes/DocsWebProbeUI.cs if you want to see the pause, hide, and clip ideas exercised with the kit controls.
see also¶
- webpanel guidance - the wrapper pattern for container-only styles and the pause pattern explained.
- webpanel reference - the full property table for
WebPanel, including the border-radius group. - build method - how
Rebuild()schedules a freshBuild()and howKeydrives structural diffing. - managing state - the field-plus-auto-rebuild loop this article's buttons use, and where
State<T>picks up when a value changes outside a handler. - events - all nine event properties and the
AutoRebuildOnEventsauto-gate in full. - container reference - the full layout and style surface for
Container, includingOverflowandBorderRadius. - dynamic children -
Each.OfandChildren.AddRangein full, including keying rules for lists that reorder. - drawing a gauge -
Layout.Stack/Layout.Layerfor overlapping children, worked through withSectorshapes. - styles - the wrapper pattern for all engine-special blobs that expose a smaller style surface than
Container.