The Declarative Surface (authoring without new)A screen is plain C# expressions: no markup language, no builder ceremony, and no new:
public override VisualNode Build(ComponentContext context) =>
Column(gap: Space.S4, children: [
Text($"Count: {_count}", TypeRole.Display, context.Theme.TextPrimary),
Row(gap: Space.S3, children: [
Button("Up", onPressed: () => SetState(() => _count++)),
Button("Reset", Variant.Outline, onPressed: () => SetState(() => _count = 0)),
Every name there is a factory method named exactly like the type it returns. There is no import to write: the SDK puts the framework's surface in scope in every file of your project, and your own components join it automatically (see below).
Because styles are typed values rather than CSS strings, the compiler checks the whole interface, layout and styling included, and the same class renders on the web and natively through Photon. •
A factory is named exactly like its type. Column, Text, Button.
•
It mirrors a constructor parameter for parameter (same names, same order, same defaults), so named arguments carry between new X(…) and X(…) unchanged.
•
After the mirrored parameters, a factory may take an optional semantic tail: parameters that each match a public init-only property of the node (same name, same type) and are applied via an object initializer. This is how a declarative screen states what constructors deliberately do not carry: `Pressable(child, onPressed, label:, selected:, disabled:, pressedBackground:, expanded:), Link(destination, child, label:, current:), and TextEntry(value, onChanged, label:, placeholder:, disabled:, obscure:). Composite and modal machinery (Role, Mixed`, InitialFocus) stays initializer-only on purpose: it belongs to the components that own those patterns.
•
Container nodes take a final trailing children parameter, written as a collection expression.
•
There are no overloads. The surface transpiles to a JavaScript twin, and JS methods cannot overload, so each type has ONE canonical factory.
Layout is parameters, not an initializer
Since 0.2.0-preview.13
Row(gap: Space.S3, main: MainAlign.SpaceBetween, cross: CrossAlign.Start, children: [ … ])
Column(gap: Space.S2, wrap: true, runGap: Space.S4, children: [ … ])
Text("42", TypeRole.Display, align: TextAlignment.Center, tabular: true)
Semantics are parameters too
Since 0.2.0-preview.45
An icon-only button VoiceOver can name, a nav item that STATES it is selected, the link to the page the reader is on, all reachable without leaving the factory form:
Pressable(Icon(Icons.Search), onPressed: Search, label: "Search", selected: true)
Link("/scan", Icon(Icons.Home), label: "Home", current: true)
TextEntry(_password, OnPassword, label: "Password", obscure: true)
A control pressed while the keyboard stays where it is, a formatting toolbar's button beside the text it formats or a row of suggestions under a field, says so with canRequestFocus: false: the press runs, and the focus stays where it was.
Pressable(Icon(Icons.Check), onPressed: Apply, label: "Apply", canRequestFocus: false)
Since 0.2.0-preview.61
A container that fills its parent (the most common layout there is) no longer needs new: Column, Row, Grid, Stack, ScrollView and ListView take width and height before children (a SizeValue; leaving them out is Hug, exactly what an initializer without them means), and Pressable takes its composite role. Counted by a real app after migrating its whole UI: 49 places that had stayed imperative for a width, 3 for a role.
Row(gap: Space.S2, width: SizeValue.Fill, children: [ … ])
Column(height: SizeValue.Fill, children: [ ScrollView(list, height: SizeValue.Fill) ])
Pressable(Text("Overview"), Select, selected: true, role: PressableRole.Radio)
Since 0.2.0-preview.46
And the settings controls
Three more from the same report, and the same rule. A Switch announces itself and goes inert while a batch applies; a Checkbox goes inert too; and SegmentedControl had no factory at all, so a tab strip stayed imperative for one knob.
Switch(on: item.Enabled, onChanged: Toggle, label: item.Name, disabled: applying)
Checkbox(includeSystem, onChanged: Flip, label: "Include system files", disabled: scanning)
SegmentedControl(["Apps", "Login items"], selected, onChanged: Pick, stretch: false)
A list of switches with no names is a list of identical controls to assistive technology, which is why label is the first of them.
Since 0.2.0-preview.46
A link needs somewhere to arrive
Bookmark names a place in a screen that a link can reach: Link("#features", …) arrives at the node that says Bookmark = "features".
Box(SectionStyle, children: [ … ]) with { Bookmark = "features" }
It is deliberately not Key, and the confusion cost a site its whole landing nav plus the "On this page" rail of its docs: a Key is reconciler identity, unique among SIBLINGS only, and in a list it is typically "1", "2", "3", and projecting that onto a screen-wide name collides on the first repeated list. A node can carry both.
Nothing validates the value beyond emptiness, on purpose: bookmarks are frequently GENERATED (a docs rail slugs the headings it finds), and refusing a duplicate at build time would fail a page because the source it renders grew a repeated heading.
On the web it becomes the element's id, and the realizer gives it room to land: a browser scrolls a target to the very top, so under a sticky header it would arrive hidden. The offset is measured from the sticky the app already declared, never a number repeated in the page: one site has a 60dp nav on its landing and a 56dp topbar in its docs and states neither. On Photon it is inert today: a bookmark is somewhere to scroll to, and programmatic scrolling is still ahead.
Since 0.2.0-preview.46
Because Stack gained two knobs before children, its children is named, Stack(Alignment.Center, children: [ … ]), the rule every other container already followed.
Row and Column take main, cross, wrap, runGap and padding; Text takes align, mono, tabular and styleOverride. These were init-only properties, so setting one meant an object initializer, which means new, and new is exactly what this surface removes. A row that had to centre its content dropped out of the surface entirely and had to be written the old way.
Width, height, background and corner radius are deliberately not parameters on a flex: a flex carrying those is a Box wrapping a flex, and the properties already say so.
styleOverride on Text is the way out of a closed type scale. The rungs are the right default and a design that reaches past them everywhere has stopped having a scale, but a closed scale with no way out gets worked around by nesting a raw HtmlElement, which is worse, because it only works on one target.
BREAKING IN 0.2.0-PREVIEW.13 children is trailing (the container contract), so the knobs sit between it and gap: Column(Space.S3, [ … ]) becomes Column(Space.S3, children: [ … ]). One word, and it is the surface's normal form already.
Rarer init properties keep the constructor + initializer form; the factories are sugar over the same types, never a second API:
Box(new BoxStyle { Padding = EdgeInsets.All(Space.S4), Background = theme.Surface },
Text("Still the same Box", TypeRole.BodyM))
Value records (GridTrack, DialogAction, NavItem) deliberately have no factories: they are data, and target-typed new(…) already reads well.
Your own components join it
Define a component; the build generates its factory. Nothing to register, nothing to import:
// Components/StatTile.cs
public sealed class StatTile : StatelessComponent
public StatTile(string label, string value) { Label = label; Value = value; }
public string Label { get; init; }
public string Value { get; init; }
public override VisualNode Build(ComponentContext context) => /* … */;
// Pages/HomePage.cs, no using, no new
Row(gap: Space.S3, children: [
StatTile("Count", $"{_count}"),
StatTile("Doubled", $"{_count * 2}"),
A source generator reads the compilation, finds the components, and writes a static AppUI class plus the global using static that puts it in scope. It stays in step with your components because it is generated from them.
Pages get no factory. A [Page] is reached by its route, never composed by hand.
Which constructor is mirrored
The widest, the same rule the transpiler applies when it collapses constructor overloads, so the factory and the emitted constructor never disagree. When the widest is not the one you want offered, elect another:
public sealed class Badge : StatelessComponent
public Badge(string label) { … } // ← this one gets the factory
public Badge(string label, int count, bool dot) { … }
EQ3101
Error
Two constructors of one component are marked [UiFactory]. An election needs a single winner.
EQ3102
Warning
Two components share a name, so only one can own that factory. Rename one, or build the other with new.
<EQuanticGenerateFactorySurface>false</EQuanticGenerateFactorySurface>
Your components then compose with new, exactly as before.
The one sharp edge: a factory shadows its type
A method named like a type shadows that type in any file where the surface is in scope. It bites only on types that arrive through a using, which means the framework's, not yours:
Spacer.Fixed(34) // ✗ CS0119: `Spacer` binds to the factory method
Panel.Empty("x") // ✓ your own type, declared in your own namespace, wins
C# resolves names declared in the current namespace ahead of using static imports, so your own components are never shadowed by their own factories, and you never need the workaround below.
The framework's own statics that sit behind a factory name get a factory under a name of their own:
Badge.AsDot(variant)
DotBadge(variant)
new Icon(packGlyph)
Glyph(packGlyph)
Since 0.2.0-preview.7 (the Glyph row; the other two since 0.2.0-preview.2)
A conformance test walks every factory, looks for statics on the type it shadows, and fails naming them until each has a named factory, so a fourth one cannot appear unnoticed.
Glyph is there for a slightly different reason than the other two, and it is worth knowing which: Icon has two constructors, one taking the framework's curated Icons enum, one taking the IconGlyph an icon package hands out. The mirrored Icon(...) can only be one of them, and there are no overloads here, so a pack glyph had no way into a file importing this surface at all: it could only be drawn with new, in the one place the framework promises you never need it.
using eQuantic.UI.MaterialSymbols;
Glyph(MaterialSymbolsIcons.PlayArrowRounded) // any icon package's catalog
Icon(Icons.Check) // the curated set
How it reaches the browser
The generated surface is written to disk (EmitCompilerGeneratedFiles) because eqc reads files, not the C# compilation: a factory your page calls has to be part of what the transpiler sees, or the call resolves to nothing and the emitted JavaScript degrades silently. It is then transpiled into a module like any other class, so AppUI.statTile(…) exists in the bundle beside UI.column(…).
A GENERATOR'S LEFTOVERS GO WITH IT The compiler removes no generated file, so a component you delete, or a generator you rename or remove, would leave its last output behind for eqc to read: a phantom type, or a second copy of a live one. The SDK removes them itself: after a compile that runs, a generated file that compile did not write is deleted, and the build log names it. No dotnet clean is needed.
•
Components: the catalog every factory corresponds to