Skip to content

YAML UI Definition

Mateu can render a page defined entirely in a YAML file — no Java class required.

When the framework receives a request for a route that has no matching Java class, it looks for a YAML file in the classpath and uses it to build the component tree automatically.

  1. A request arrives for route my-app/my-page.
  2. Mateu finds no Java view model bound to that route (a @UI mount or a routes.yaml entry).
  3. Mateu looks for specs/ui/my-app/my-page.yaml in the classpath.
  4. If found, it deserializes the file into a component tree and renders it.
  5. If not found, Mateu falls back to “Not found”.

Place YAML files under src/main/resources/specs/ui/, mirroring the route structure:

RouteYAML file
hellosrc/main/resources/specs/ui/hello.yaml
demo/hellosrc/main/resources/specs/ui/demo/hello.yaml
admin/users/listsrc/main/resources/specs/ui/admin/users/list.yaml

Every YAML file must have a root type field that names the component to render. All nested components also need a type.

type: VerticalLayout
spacing: true
padding: true
content:
- type: Text
text: "Hello from YAML!"
container: h1
- type: Text
text: "This page was defined in a YAML file, without any Java class."
- type: HorizontalLayout
spacing: true
content:
- type: Button
label: "Save"
actionId: save
buttonStyle: primary
- type: Button
label: "Cancel"
actionId: cancel
buttonStyle: secondary
- type: FormLayout
content:
- type: FormField
id: name
label: "Name"
dataType: string
required: true
- type: FormField
id: email
label: "Email"
dataType: string
stereotype: email
- type: FormField
id: birthDate
label: "Birth date"
dataType: date

A page has a coarse page type — landing, collection, detail, form, process or dashboard (the Redwood page-template families). It sets the page’s default width (form, process and landing pages are capped; collection, detail and dashboard pages defer to their content) and is stamped on the rendered page as data-page-type. A Java view declares it with @PageTemplate(PageType.X); a YAML page declares it with a top-level pageType: key, beside layout: (or beside type: in a bare tree):

pageType: landing
layout:
type: VerticalLayout
content:
- type: HeroSection
title: Welcome
  • Declared, it wins over the type inferred from the content or from a bound viewModel: — the same precedence as @PageTemplate (and, like the definition’s layout:, over a @PageTemplate on the view model it binds, since the definition is the more specific of the two).
  • Absent, nothing changes: the type is inferred as before.
  • Matched without regard to case; an unknown value is ignored (and logged), so a typo never costs the page.
  • It reaches the wire on every path — served by Java, .NET or Python, pre-rendered into a static bundle, or expanded in the browser from a specsOnly bundle — and every renderer (Vaadin, Redwood) reads it from there.

The New › Mateu › Page… templates (IntelliJ and VS Code) already write the right one: Welcome and Hero Search → landing; Listing, Smart Search, To-do List, Calendar, Collection Detail and the grid-like pages → collection; General/Item Overview, Foldout and Gantt → detail; Dashboard → dashboard; Wizard step → process; Form → form. The blank layout leaves it to inference.

A page’s header lives on its type: Form node: title, subtitle, avatar, header, toolbar, buttons, footer, noHeader — and every other element a Java page gets from its annotations and suppliers, now authorable as data:

KeyShapeJava twin
overlinetext above the title@Overline / OverlineSupplier
titlePlaceholderwhat the header shows while the title is empty@TitlePlaceholder
timestampthe “last updated” line under the subtitle@Timestamp field
badgeslist of {label, color} chips@BadgeInHeader / BadgeSupplier
kpislist of {label, value} key facts@KPI
peerNav{prevLabel, prevRoute, nextLabel, nextRoute}PeerNavigationSupplier
recordSwitcher{label, value, type, searchable, disabled, options | optionsSource, route | actionId}RecordSwitcherSupplier
bannerslist of {theme, title, description, closeable, timeoutSeconds}@Banner / BannerSupplier
fabslist of {icon, label, actionId, order, buttonStyle}@Fab methods on the page
type: Form
title: ${state.name}
overline: Customers
titlePlaceholder: New customer…
timestamp: Last updated ${state.updatedAt}
badges:
- {label: "${state.status}", color: success} # blank once interpolated → hidden
kpis:
- {label: Balance, value: "${state.balance}"}
peerNav: {prevLabel: Previous, prevRoute: "customers/${state.prevId}", nextLabel: Next, nextRoute: "customers/${state.nextId}"}
recordSwitcher:
label: Customer
value: ${state.id}
searchable: true
optionsSource: {ref: customers, itemsPath: content, valuePath: id, labelPath: name}
route: customers/${state._record} # the picked value is ${state._record}
banners:
- {theme: WARNING, title: Overdue, description: "Two invoices are overdue", closeable: true}
fabs:
- {icon: "vaadin:plus", label: New order, actionId: newOrder}
content: [...]
  • Dynamic values use the idioms every YAML text already has: ${state.x} and ${data.x} (the route’s data: source feeds data), interpolated by the renderer. A badge whose text interpolates to blank is hidden — the same switch a @BadgeInHeader String field gives.
  • The record switcher’s entries are either listed (options: [{value, label, description}]) or fetched by the browser from a REST source (optionsSource:, usually a sources.yaml reference, read with its itemsPath/valuePath/labelPath — like a select field’s options). A pick navigates to route (with ${state._record} = the picked value), or, without a route, dispatches actionId (default _switchRecord) with the value in _record — a page actions: entry or a view model method can take it from there. The source is fetched directly by the browser, so a proxy: true source does not serve a switcher.
  • Banner themes are INFO, SUCCESS, WARNING, DANGER (upper case).
  • FABs dispatch their actionId like any button: an action the page declares in actions:, an action-catalogue entry, or a method of the bound view model. They stack by order (ties keep the list order); buttonStyle defaults to primary.
  • They reach the wire with the same field names a reflected Java page’s header has (overline, titlePlaceholder, timestamp, badges, kpis, peerNav, switcher, banners, fabs), so every renderer draws them alike — served by Java, .NET or Python, pre-rendered into a static bundle, or expanded in the browser from a specsOnly bundle.
  • Redwood draws every one of them, for a YAML page and a Java page alike: KPIs, subtitle, switcher (listed or from a direct source; route picks navigate), banners and FABs, and on its oj-sp page header the status badge (the first header badge — the rest follow the subtitle, and a badge whose label resolves blank is not drawn), the previous/next arrows (a side with no route is drawn disabled; both empty, no arrows), the timestamp (the header’s own “last updated” element when the value is a date — 2026-10-12T10:42 — otherwise the text ends the subtitle line), the overline above the title and the title placeholder (dimmed, only while the title resolves empty).

All component types from the io.mateu.uidl.data package are supported. Use the exact class name as the type value.

Layout components: VerticalLayout, HorizontalLayout, FormLayout, TabLayout, SplitLayout, MasterDetailLayout, AccordionLayout, BoardLayout, CarouselLayout, Grid, …

Content components: Text, Markdown, Image, Icon, Badge, KPI, Chart, ProgressBar, Anchor, Bpmn, Workflow, …

Input components: FormField, CustomField, FormSection, FormRow, …

Action components: Button, Dialog, ConfirmDialog, …

Navigation: RouteLink, Menu, Directory, Breadcrumbs, …

The complete list of types and their fields is in the JSON Schema: https://raw.githubusercontent.com/miguelperezcolom/mateu/refs/heads/master/backend/shared/uidl/uidl-schema.json

The project ships a JSON Schema at https://raw.githubusercontent.com/miguelperezcolom/mateu/refs/heads/master/backend/shared/uidl/uidl-schema.json. Connecting your editor to it gives you autocompletion, field validation, and documentation tooltips while editing YAML files.


Option A — workspace settings (recommended)

Install the YAML extension by Red Hat, then add this to .vscode/settings.json:

{
"yaml.schemas": {
"./backend/shared/uidl/uidl-schema.json": "src/**/specs/ui/**/*.yaml"
}
}

This activates IntelliSense for every *.yaml file under any specs/ui/ directory in the project.

Option B — per-file comment

Add this comment at the top of any individual YAML file:

# yaml-language-server: $schema=https://raw.githubusercontent.com/miguelperezcolom/mateu/refs/heads/master/backend/shared/uidl/uidl-schema.json
type: VerticalLayout
...

Adjust the relative path so it points to uidl-schema.json from the YAML file’s location.


  1. Open Settings (⌘, / Ctrl+Alt+S).
  2. Go to Languages & Frameworks → Schemas and DTDs → JSON Schema Mappings.
  3. Click + to add a new mapping.
  4. Fill in:
    • Name: Mateu UIDL
    • Schema file or URL: click the folder icon and select https://raw.githubusercontent.com/miguelperezcolom/mateu/refs/heads/master/backend/shared/uidl/uidl-schema.json
    • Schema version: JSON Schema version 7
  5. Under File path pattern, click + and enter: specs/ui/**/*.yaml
  6. Click OK.

IntelliJ will now provide autocompletion and validation for all YAML files under specs/ui/.

Tip: If IntelliJ shows a yellow bar saying “Schema is not applied”, make sure the file path pattern matches — use specs/ui without a leading slash.


A YAML file under specs/ui/partials/ is not a page but a partial — one component, or a list of them, usable anywhere a component is:

- type: Partial
ref: address-block

See Partials.

A page can carry a layoutDelta: instead of a layout:. The difference matters more than it looks:

viewModel: com.acme.Contact
layoutDelta:
order: [email, name]
hidden: [internalNote]
overrides:
name: { label: "Full name", colspan: 2 }

Vocabulary: the key that names the view model is viewModel: (the canonical noun of the 5-noun model). The older modelView: is still accepted as a deprecated alias so existing definitions keep working; prefer viewModel: in new files.

A layout: is a snapshot: it takes the screen out of the inference regime for good, because explicit always wins. Add a field to the model afterwards and it does not appear; rename one and the layout points at a ghost.

A layoutDelta: records what a human decided, anchored to field ids. Inference still runs on every request and the delta is re-applied on top, so a field the model grows later lands in its inferred place, and one it loses is an entry that simply matches nothing.

What a delta deliberately cannot express is arbitrary restructuring — “wrap these two in a card inside a tab”. Anchoring to ids is exactly what makes it survive a model change; a delta that could rebuild the tree freely would be a snapshot with a different name.

The delta is re-applied to the tree inference produced on every request — that is the whole difference from a layout:, which is computed once and then stands in for the model forever.

An inferred tree may spread the model’s fields over sections, tabs or rows, so:

hiddenremoves that field wherever it sits
overridesapply to that field wherever it sits
orderreorders the fields within each container that holds them, leaving non-field siblings in their slots

A delta cannot move a field between containers, and cannot restructure the tree. That limit is not an omission — it is the reason a delta survives a model change at all.

Drag a field in the editor and it saves a layoutDelta:, not a snapshot. Two consequences worth knowing:

  • Opening a screen and changing nothing writes layoutDelta: {} — an empty delta, which costs the screen nothing. Using the editor must not, by itself, end a page’s inference.
  • Some edits cannot be a delta. Add a Text, wrap two fields in a card, set a stereotype by hand — none of that is expressible as “these fields, in this order, with these tweaks”. The editor then falls back to writing a layout: and shows a snapshot badge instead of a delta one, so the moment a screen leaves inference is visible rather than silent.

An existing layout: page migrates by itself: open it in the editor, rearrange, and if the layout is a flat run of the model’s own fields the next save comes back as a delta.

Hybrid: YAML layout + Java logic with @UISpec

Section titled “Hybrid: YAML layout + Java logic with @UISpec”

A pure YAML file has no Java class, so it cannot run server-side logic. A pure Java ComponentTreeSupplier has no YAML file, so the layout is hard-coded.

@UISpec is the middle ground: a Java ViewModel class whose component tree is loaded from a YAML file. The Java class provides state (fields), actions (methods), rules, and validations; the YAML file defines the layout.

@UISpec("specs/ui/customer-form.yaml")
public class CustomerFormViewModel {
public String name;
public String email;
@Button
public void save() { ... }
}
src/main/resources/specs/ui/routes.yaml
type: Routes
routes:
- route: customer-form
viewModel: com.example.CustomerFormViewModel

The YAML file at src/main/resources/specs/ui/customer-form.yaml defines the component tree rendered for this class. Mateu combines the two: layout from YAML, behaviour from Java.

See @UISpec for the full reference.

SituationRecommendation
Static informational page (about, help, landing)Pure YAML file
Page with server-side logic, computed fields, actions@UISpec or ComponentTreeSupplier
Prototype or low-code scenarioPure YAML file
Page that needs dependency injection or DB access@UISpec or Java class
Designer-managed layout, developer-managed logic@UISpec
Fully dynamic / programmatic layoutComponentTreeSupplier

YAML pages and Java pages can coexist freely in the same application.