Skip to content
Flow-UILive
On this page

Starter widgets as worked examples

Starter widgets are examples you can override: title block, image text card, banner, tag rail, stepper, accordion, separator, button row.

Ayush MishraPublished 8 min read

Starter widgets are examples you can override: title block, image text row, banner, tag rail, stepper, accordion, separator, and button row. They are not a design system you must ship, and they are not a closed catalogue the backend is stuck with. They are eight worked WidgetView types in the FlowWidgets module, registered through the same open table as your own widgets.

The honest use is dual. New hosts get a visible page on day one. Teams that already have a visual language read the source, then replace each type string with a view that looks like the rest of the app. Last registration per type wins, which is the next article in this series. This one stays on what the eight examples teach.

If you are still deciding whether a surface belongs in server driven UI at all, read when not to use server driven UI first. Starter widgets will not make a map canvas or a game loop a good JSON problem. They will make a feed, a merchandising grid, an FAQ, and a promo strip boring in the good way.

Register the library, then forget it is special#

FlowWidgets.all is a computed list of the eight view types. It is computed rather than stored because a stored array of existential metatypes is shared mutable global state under Swift 6. FlowWidgets.register(on:) calls registry.register(all).

FlowWidgets.swiftswift
public static var all: [any WidgetView.Type] {
    [
        TitleBlockWidget.self,
        ImageTextCardWidget.self,
        SeparatorWidget.self,
        ButtonRowWidget.self,
        TagRailWidget.self,
        BannerWidget.self,
        StepperRowWidget.self,
        AccordionWidget.self
    ]
}

Call that once at launch, before or after your product widgets. Order only matters when two registrations share a widgetType string.

swift
FlowWidgets.register(on: registry)

The widget catalog in the docs is the JSON contract: Widget catalog. Each starter page there is also a live render. This article is the why of each shape, and which pattern to copy when you write the ninth type.

Eight shapes, eight lessons#

WidgetType stringWhat it teaches
Title blocktitle_blockHeading plus optional subtitle as TextData
Image text rowimage_text_cardThe workhorse list row: image, title, tags, trailing icon
BannerbannerFull width image, optional overlay text, tap on the envelope
Button rowbutton_rowSeveral buttons, each with its own ActionData
Tag railtag_railHorizontal chips, optional per-tag actions
Stepper rowstepper_rowEphemeral count in WidgetStateStore, change event
AccordionaccordionNested AnyWidget children through the same registry
SeparatorseparatorBackend placed divider, payload is the atom itself

Those type strings are the contract. Do not invent hero_banner in the app if the backend already sends banner. Do not send card if the starter you meant is image_text_card. Rename only when you are replacing the type on both sides.

Title block: section openers without a new view per heading#

title_block is a title and an optional subtitle. Fonts and colors ride on TextData, so merchandising can change weight and size without a new widget type. Copy this when the backend needs a labelled group and you are tempted to bake the heading into the next widget’s padding.

The view ignores WidgetContext. Display-only widgets may do that. The initializer still takes context because the protocol requires it.

Image text row: the list workhorse#

image_text_card is leading image, title, subtitle, optional tags, optional trailing icon. Envelope tap usually carries the destination. This is the row you will override first if your app already has a product cell with a specific image ratio and tag style.

Notice the name in JSON is image_text_card even though the mental model is a widget in a section, not a free-floating “card” system. Keep talking in pages, sections, and widgets. The type string is just a key.

banner requires an image. Title and subtitle are optional overlays. It pairs naturally with a carousel section. Tap is envelope level, often a host deeplink action. The starter does not fetch. Image loading is a host seam, the same way PageLoader is a host seam for page documents. Flow-UI does not perform network calls.

Button row: actions live on the controls#

button_row decodes buttons through LossyArray, so one malformed button drops and the rest of the row survives. Each ButtonData may carry its own ActionData. The widget dispatches those explicitly via WidgetContext. Copy this whenever a payload embeds controls rather than relying on envelope tap.

Tag rail: a horizontal filter strip#

tag_rail is the same lossy-array idea for tags. Some tags are display. Some carry api actions that filter the page. The rail scrolls horizontally. If your app already has a chip style, this is a clean override target: same tag_rail type, your view.

Stepper row: state that is not in the JSON#

stepper_row stores the count in WidgetStateStore under the widget id and the key the widget chooses ("count" in spirit, bound through context.state). The JSON has min, max, and initial. Every change fires the envelope change action. The backend learns the new value on the api round trip, not by mutating the decoded payload in place.

This is the worked example for ephemeral UI state. Refresh clears the store. Pagination appends leave it untouched. Send a real id on anything with a stepper or you will collide and reset the wrong row.

Accordion: nesting is just more widgets#

accordion holds a header TextData, an optional initially_expanded, and items: [AnyWidget]. Children decode through the same registry. Host widgets work inside it with no extra registration step beyond being in the table already. Nesting is not a second renderer. The next article in this series is the deep dive. The lesson here is that composition is data: a widget whose payload contains widgets.

Separator: the payload is the atom#

separator does not nest a separator key inside data. The data object is SeparatorData (style, insets). Hand-written init(from:) is how that works. Copy this when an atom and a widget would otherwise duplicate the same JSON shape.

What these examples are not#

They are not a requirement. The FAQ is blunt: you do not have to use them. Register your own types and ship a page that never mentions title_block.

They are not a visual identity. If you ship them unmodified into a consumer app that already has a design language, the page will look like a catalog, not like your product. Override or replace.

They are not an excuse to grow an HTML-in-JSON framework. Eight types cover everyday merchandising. A ninth type should be a product noun (order_card, shipment_tracker), not div with inline styles. Jacob Bartlett’s line about specialised SDUI versus hubris still applies; the starter set is specialised on purpose.

Skeletons and previews#

Several starters conform to WidgetSkeletonProviding so loading states can approximate the real shape. #Preview blocks in the same files show the JSON-shaped content without a PageStore. When you write your own widget, a preview with WidgetContext.inert() is the cheapest regression test you will actually run.

How a backend should think about the set#

Prefer atoms (TextData, ImageData, TagData, ButtonData) over parallel string fields. A title that is sometimes a raw string and sometimes an object still decodes in FlowCore, but a team that always sends objects gets fonts and dark colors for free.

Prefer envelope actions for whole-row taps. Prefer embedded ActionData on buttons and tags when several controls share a widget.

Prefer stable id values on steppers, accordions, and anything an api mutation will replace_widget later.

Prefer adding a new type over overloading image_text_card until it grows a dozen optional blobs. The registry is open. A dedicated order_card is cheaper to maintain than a workhorse row that means six things.

Host checkout#

At launch: empty WidgetRegistry, FlowWidgets.register(on:), then registry.register(OrderCardWidget.self) and the rest of your product types. Pass that registry into PageStore. FlowPageView reads it from the environment. Nested widgets and sheets use the same table.

If a starter type will never appear in your JSON, you can skip FlowWidgets.register and register only the types you need. There is no hidden dependency that requires separator to be present for the page to decode. Unregistered types become unknown widgets and follow the unknown policy.

Where this sits in the series#

WidgetView, WidgetContext, and AnyView erasure is the protocol these eight conform to. How to build server driven UI in SwiftUI is the four-seam version. Native rendering, not a WebView, is still the point: SDUI vs WebViews on iOS.

Use the starters as readable source. When a type’s layout is wrong for your app, override that type string rather than forking the package. When the backend needs a noun the starters do not have, add a widget. That is the whole catalogue strategy.

The Flow-UI mark: two nodes joined through a filled centre

Eight examples, one registry, no obligation to ship the examples unchanged.

More on the blog.

Read the source

Flow-UI is MIT licensed. The schema, renderer and starter widgets live on GitHub.

GitHub

Get started with Flow-UI

Install the Swift package, register a widget, and render a page from JSON.

Get started