Skip to content

Composition

Components compose with JSX-like tags while keeping Go values and package boundaries.

Calling components

Call a component with its name and pass declared inputs as attributes. Use a self-closing tag when it has no nested content.

gsx
package views

component Card(title string, featured bool, count int) {
	<div class={ "card", "card-featured": featured }>
		<h2>{ title }</h2>
		<span>{ count }</span>
	</div>
}

component Page(t string, n int) {
	<Card title={t} featured count={n}/>
}

Renders:

html
<div class="card card-featured"><h2>Hi</h2><span>3</span></div>

▶ Open in Playground

Here Page passes strings, numbers, and the bare boolean input featured to Card. Package-qualified calls use the same form: <ui.Button label="Save"/>. Names match the callee's authored parameters exactly.

Generic components

Declare Go type parameters after the component name. gsx infers type arguments from the inputs supplied at each call site.

gsx
package views

component Badge[T string | int](value T) {
	<span class="badge">{ value }</span>
}

component Page() {
	<Badge value={"new"}/>
	<Badge value={42}/>
}

Renders:

html
<span class="badge">new</span><span class="badge">42</span>

▶ Open in Playground

Both Badge calls infer T from value, so string and integer instances can appear together in the same component body.

Directly interpolating T requires a renderable constraint; with T any, convert or format it to a supported type, such as { value |> printf("%v") }.

Explicit type arguments

Use Go-style brackets when inference is ambiguous or when the call should state the intended type directly.

gsx
package views

component Price[T int | float64](amount T, currency string) {
	<b>{ currency }{ amount }</b>
}

component Page() {
	<Price amount={9.99} currency="$"/>
	<Price amount={42} currency="€"/>
	<Price[float64] amount={4} currency="£"/>
}

Renders:

html
<b>$9.99</b><b>€42</b><b>£4</b>

▶ Open in Playground

<Price[float64] amount={4} currency="£"/> keeps the amount in the float64 case even though an untyped 4 would otherwise infer int.

Children {children}

Declare children gsx.Node, then write {children} where the component should render the content between its opening and closing tags.

gsx
package views

import "github.com/gsxhq/gsx"

component Card(title string, children gsx.Node) {
	<article class="card">
		<h3>{ title }</h3>
		<div class="card__body">{ children }</div>
	</article>
}

component Page() {
	<Card title="Hello"><em>composed</em></Card>
}

Renders:

html
<article class="card"><h3>Hello</h3><div class="card__body"><em>composed</em></div></article>

▶ Open in Playground

Card owns the surrounding markup and chooses the exact placement of the caller's <em>composed</em> node. Use children ...gsx.Node when the component needs each static top-level child separately; inside the body it is a []gsx.Node.

The body is the only fill source for lowercase children; children={...} is rejected. A component without this parameter rejects a non-empty body. See Reserved component inputs.

Named slots

Use ordinary gsx.Node parameters for additional content positions such as a header or footer. Pass markup inline in the matching attribute.

gsx
package views

import "github.com/gsxhq/gsx"

component Panel(header gsx.Node, footer gsx.Node) {
	<div class="panel">
		<header>{ header }</header>
		<footer>{ footer }</footer>
	</div>
}

component Page() {
	<Panel header={ <h1>H</h1> } footer="F"/>
}

Renders:

html
<div class="panel"><header><h1>H</h1></header><footer>F</footer></div>

▶ Open in Playground

Named slots and children can be used together: named slots receive explicit attributes, while {children} receives the content inside the tag.

Cross-file and cross-package calls

Components in different .gsx files of the same package call each other by name. Imported components use their Go package qualifier.

components.gsx

gsx
package views

import "github.com/gsxhq/gsx"

component Button(label string) {
	<button class="btn">{ label }</button>
}

component Card(title string, children gsx.Node) {
	<section class="card">
		<h2>{ title }</h2>
		{ children }
	</section>
}

page.gsx

gsx
package views

type HomePage struct {
	Title string
}

component (p HomePage) Render() {
	<main>
		<Card title={p.Title}>
			<Button label="Save"/>
		</Card>
	</main>
}

Renders:

html
<main><section class="card"><h2>Dashboard</h2><button class="btn">Save</button></section></main>

▶ Open in Playground

Normal Go visibility applies: unexported components stay within their package, and exported components can be called as <ui.Button .../> after importing ui.

Explicit attribute forwarding

Undeclared component attributes are accepted only when the component declares an attrs parameter. Put { attrs... } on the element that should receive them.

gsx
package views

import "github.com/gsxhq/gsx"

component Button(variant string, children gsx.Node, attrs gsx.Attrs) {
	<button class="btn" data-variant={variant} { attrs... }>{ children }</button>
}

component Page() {
	<Button variant="primary" class="w-full" data-test="x" hx-post="/go">Save</Button>
}

Renders:

html
<button class="btn w-full" data-variant="primary" data-test="x" hx-post="/go">Save</button>

▶ Open in Playground

The component can forward the bag, split selected values across elements, or omit it entirely. Every unmatched input and explicit attrs contributor enters the bag at its authored position. See Attributes — Spread for bag syntax and Escaping for the trust rules applied at the destination.

For JavaScript or CSS attributes with dynamic holes, accept ordinary parameters and build the contextual literal on the final native element; see JavaScript. A component that intentionally accepts trusted code may instead declare a gsx.RawJS or gsx.RawCSS parameter and receive a braced contextual value.

Precedence

The spread's source position controls scalar attributes:

gsx
<button type="button" { attrs... } disabled class="button">Save</button>
  • Before the spread, type is a default that the bag can override.
  • After the spread, disabled is forced by the component.
  • class and style compose instead of replacing one another.

Derived bags

The spread can use any expression that produces gsx.Attrs:

gsx
<input { attrs.Without("type")... }/>
<div { attrs.Merge(extra)... }>...</div>
<span { p.Attrs.Without("id")... }>Label</span>

Forwarding through components

A component can pass its fallthrough bag into another component call. Each component in the chain still chooses where the attributes finally land.

gsx
package views

import "github.com/gsxhq/gsx"

component Icon(name string, attrs gsx.Attrs) {
	<span class="icon" data-name={name} { attrs... }>i</span>
}

component SearchIcon(attrs gsx.Attrs) {
	<Icon name="search" class="w-5 h-5" { attrs... }/>
}

component Page() {
	<SearchIcon class="text-red" aria-label="Search"/>
}

Renders:

html
<span class="icon w-5 h-5 text-red" data-name="search" aria-label="Search">i</span>

▶ Open in Playground

SearchIcon adds its default class, then forwards the outer caller's class and ARIA label through Icon to the final <span>. The target must declare its own attrs parameter; a composite value parameter does not implicitly accept a bag.

Method components

Declare a component as a method when several views share state held by a named receiver. Authored parameters remain the method's exact Go signature and the per-call markup data.

gsx
package views

type UsersPage struct {
	Title string
	Sort  string
}

component (p UsersPage) Page() {
	<div>
		<p.Grid sort={p.Sort}/>
	</div>
}

component (p UsersPage) Grid(sort string) {
	<span>{ sort }-{ p.Title }</span>
}

Renders:

html
<div><span>name-Team</span></div>

▶ Open in Playground

Call another method component through the receiver, as in <p.Grid sort={p.Sort}/>. This keeps page-level state on p without threading it through every component parameter.