Composition
gsx components are ordinary Go values — they compose by calling each other. A component declaration compiles to a Go function; calling it inside another component's body is the same as calling any Go function. There is no runtime template registry and no virtual-DOM layer: composition is just Go.
Calling components
A tag that starts with an uppercase letter (or a dotted package path like <ui.Button>) is a component call; lowercase and hyphenated tags are HTML elements. The component's declared params become a generated <Name>Props struct, and each attribute at the call site sets the corresponding field.
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:
<div class="card card-featured"><h2>Hi</h2><span>3</span></div>Card declares three params — title string, featured bool, count int — so the codegen produces a CardProps struct with matching fields. Page calls <Card title={t} featured count={n}/>: the featured bare attribute (no = value) sets Featured to true, matching the Bool shorthand for boolean props.
The Page component itself has params t string and n int, so Page(PageProps{T: "Hi", N: 3}) is valid Go at the call site. Component names are just Go identifiers; cross-package calls look like <ui.Button label="X"/> where ui is an imported package alias.
Generic components
Components can declare Go type parameters after the component name. The generated props type and component function carry the same type parameters. Component tags can pass explicit type arguments with Go-style brackets, or omit them when Go can infer the type arguments from the supplied props.
package views
component Badge[T string | int](value T) {
<span class="badge">{ value }</span>
}
component Page() {
<Badge value={"new"}/>
<Badge value={42}/>
}Renders:
<span class="badge">new</span><span class="badge">42</span>component Box[T string | int](value T) {
<span>box</span>
}
component Page() {
<Box[int] value={7} />
<Box[string] value={"ok"} />
<Box value={"inferred"} />
}This lowers to generic Go declarations shaped like type BoxProps[T string | int] struct { ... } and func Box[T string | int](p BoxProps[T]) gsx.Node. A generic tag call lowers to an explicit Go instantiation:
Box[int](BoxProps[int]{Value: 7})
Box[string](BoxProps[string]{Value: "ok"})For omitted tag type arguments, gsx asks Go's type checker to infer them during generation and then emits the same explicit shape in the final .x.go:
Box[string](BoxProps[string]{Value: "inferred"})Inference only uses information available at the component call site. If Go cannot infer a type parameter from the supplied props, generation fails with a diagnostic asking for an explicit instantiation:
type inference failed for <Box>; please instantiate with <Box[type] ...>Use explicit type arguments when a type parameter does not appear in a supplied prop, when the value is ambiguous, or when you want the call site to state the instantiation directly.
Inference sees exactly the props you supply — like an ordinary Go generic function call built from a partial argument list, not a check against the component's full declared param list. A non-generic prop that isn't needed to pin down the type parameters can be omitted entirely; the call still infers. Given component Button[T string | int](label T, size string), the call <Button label={7} /> omits size and still infers T = int, lowering to Button[int](ButtonProps[int]{Label: 7}) — Size takes its zero value. Omitting a prop never blocks inference by itself; it only fails when the props actually supplied don't mention every type parameter.
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:
<b>$9.99</b><b>€42</b><b>£4</b>An inferred call works in any body position — inside { for … } and { if … } control flow, and as a child-prop / named-slot value — exactly like an explicit instantiation would; inference runs once per call site regardless of where the tag sits.
Identifiers starting with _gsx are reserved for the generator — component params, method-component receiver names, and any other user-declared identifier must not start with that prefix. gsx's own generated code (writer locals, per-call-site inference helpers, filter-package aliases, and so on) lives exclusively in the _gsx* namespace so it can never collide with a name you write; a param or receiver named _gsxSomething is rejected at generate time.
Renderable type parameters
Interpolating a value of type parameter T directly — {value} where value T — only compiles when T's constraint fits one of two shapes:
- Same kind: every term is the same basic kind, tilde or not (
~string,int | ~int64, a single named type likeSlug). Codegen emits a static conversion (string(value),int64(value), …) that compiles for the whole type set. - Mixed kinds, all dispatchable: terms mix kinds but every term is either an unnamed predeclared type (
string,int,bool, …), an unnamed[]byte, or implementsfmt.Stringer— for examplestring | intorMyStringer | string. Codegen emits a runtime type switch that has a matching case for each term.
Anything else — a tilde term mixed with another kind (~string | int), or a named scalar term with no String() method mixed with another kind (Slug | int where type Slug string) — is rejected at generate time with error[unrenderable], because neither the static conversion nor the runtime switch covers every type in the set. Convert explicitly in the expression instead, e.g. {string(value)}.
Children {children}
When a component wraps nested markup, it accesses that markup through the special {children} placeholder. The caller places any content between the open and close tags; the component decides where it appears by writing {children} in its body.
package views
component Card(title string) {
<article class="card">
<h3>{ title }</h3>
<div class="card__body">{ children }</div>
</article>
}
component Page() {
<Card title="Hello">
<em>composed</em>
</Card>
}Renders:
<article class="card"><h3>Hello</h3><div class="card__body"><em>composed</em></div></article>Card renders {children} inside <div class="card__body">. The caller <Card title="Hello"><em>composed</em></Card> supplies <em>composed</em> as the children node. {children} is an explicit placement point in the markup: the codegen adds a Children gsx.Node field to the generated props struct whenever it appears in the body, and the caller's content is bound to that field — not passed through a templ-style context.
Content appears exactly where {children} sits inside the component, and only there. A component that does not write {children} silently drops the caller's content.
Named slots
When a component needs more than one content hole — a header, a body, a footer — declare additional params of type gsx.Node. These typed params work like named slots: the caller passes markup inline as an attribute value.
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:
<div class="panel"><header><h1>H</h1></header><footer>F</footer></div>Panel declares header gsx.Node and footer gsx.Node. The call site passes markup as header={ <h1>H</h1> } and a string literal as footer="F". For gsx.Node params, codegen converts string literals to renderable text nodes. The component renders each slot by interpolating {header} and {footer} exactly like any other variable.
This is distinct from {children}: a gsx.Node param is a named, typed field in the props struct, passed as a named attribute. {children} is the implicit content between the open and close tags.
Cross-file & cross-package
Multiple .gsx files in the same package share a single Go package, so components defined in one file are available in all others — exactly like ordinary Go. There is no explicit import between files in the same package; just split the declarations across files as you would with any Go source.
components.gsx
package views
component Button(label string) {
<button class="btn">{ label }</button>
}
component Card(title string) {
<section class="card">
<h2>{ title }</h2>
{ children }
</section>
}page.gsx
package views
type HomePage struct {
Title string
}
component (p HomePage) Render() {
<main>
<Card title={p.Title}>
<Button label="Save"/>
</Card>
</main>
}Renders:
<main><section class="card"><h2>Dashboard</h2><button class="btn">Save</button></section></main>components.gsx defines Button and Card. page.gsx defines a HomePage struct and a method component Render that composes them. The codegen treats each file as a normal Go source file within the same package build; visibility follows standard Go rules (exported identifiers are available across packages; unexported ones within the package).
Cross-package calls import the other package and use its alias: <ui.Button label="Save"/>. The generator resolves the tag through the Go type system, so refactoring — renaming a type, moving a package — is caught by the compiler like any other Go identifier.
Within the same module, gsx also discovers an imported component's declared props — including its synthesized Attrs gsx.Attrs fallthrough field — during module analysis, so a call like <ui.Panel attrs={{ "data-a": "1" }}> behaves exactly as it would for a same-package component: bare fallthrough attrs and the ordered-attrs literal split against the same declared field set and merge the same way (see Attributes — ordered-attrs literal).
For components gsx cannot analyze — packages outside the current module, or plain Go packages with no .gsx files — call-site identifier attrs are assumed to be prop fields instead of discovered ones, and attrs={{ … }} requires the Props type to declare an Attrs gsx.Attrs field explicitly (a missing field is a Go compile error at the generated call site). When a same-module dependency's props cannot be analyzed (for example, a parse or type error in its .gsx files), generation continues and gsx emits an imported-props-unavailable warning naming the dependency, falling back to the same assumed-prop treatment for its components.
Explicit attribute forwarding
Undeclared component attributes are rejected unless the component explicitly uses the attrs bag. Place { attrs... } on the element that should receive them; gsx never infers a destination from the component's root.
package views
component Button(variant string) {
<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:
<button class="btn w-full" data-variant="primary" data-test="x" hx-post="/go">Save</button>Here Button explicitly forwards class, data-test, and hx-post to its button. The explicit spread also makes wrapper components unambiguous: place the bag on the inner control, split it across elements, or omit it to expose only declared props.
Precedence
The spread's position decides who wins, JSX-style:
- attributes written before
{ attrs... }are defaults — a caller attribute with the same name overrides them; - attributes written after
{ attrs... }are forced — the component always wins and the caller's value never renders; - a conditional attribute (
{ if cond { … } }) follows the same rule for whichever branch is taken.
class and style are exempt from position: wherever they appear, they always merge — the component's tokens first, the caller's appended (then deduplicated by the configured class merger). A class written after the spread is still merged, not forced.
Derived bags
The forwarded expression doesn't have to be the bare bag. Any expression built from attrs is forwarded with the same merge-and-override semantics, and is evaluated exactly once:
<input { attrs.Without("type")... }/> // forward everything except type
<div { attrs.Merge(extra)... }>…</div> // compose another gsx.Attrs bag inThis is also how a component keeps final say over class: forward { attrs.Without("class")... } and the root's own class stands while the caller's is dropped.
An element carries one forwarding spread. To combine bags, compose them in the spread expression with Merge (later bags win per key) rather than writing two spreads — a second spread on the same element is a generate-time error.
Method components
A component can be declared as a method on a named struct, binding it to a receiver. The receiver type carries page-level state (loaded once); the component's params carry per-call data.
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:
<div><span>name-Team</span></div>component (p UsersPage) Page() and component (p UsersPage) Grid(sort string) each compile to methods on UsersPage. Inside any method component's body, the receiver name (p) is in scope for reading page state, and the declared params (sort) carry per-call data from the generated props struct.
A method component invokes another method component with <receiver.Method .../>, where receiver is the receiver variable name. In the example, Page calls <p.Grid sort={p.Sort}/>: the p. prefix routes the call to the Grid method on the same receiver value, passing sort from the current page state. This is analogous to <pkg.Name/> for package-qualified components, except p is a local variable rather than an import alias.
Method components may also declare method-owned type parameters, for example component (p Page) Row[T any](value T) { ... }, and are invoked as <p.Row[int] value={1} />. Generated Go for that form requires a go1.27+ toolchain (the first release whose go/parser accepts methods with type parameters). On an older toolchain, gsx skips the component and reports error[unsupported-toolchain]; generation continues for the rest of the package.
Method components are useful for page handlers: the HTTP handler builds the struct from the request, then the template methods read from it without threading data through every call. Multiple method components on the same receiver share the receiver's fields without any additional passing.