Gallery

Every example in the repository, captured running in a browser, beside the markup that made it.

1 / 16
Todos.sbmx running in a browser
Todos.sbmx a list, a text field, and state carried as text
:props: model: Model
:!props:

:div: class=bx-card
    :div: class=bx-spread
        :h2: class=bx-title child={"Todos"} :!h2:
        :span: class="bx-badge bx-badge-quiet" child={to_string(len(model.todos)) + " items"} :!span:
    :!div:

    :hr: class=bx-divider :!hr:

    :ul: class=list
        :for: todo in model.todos key to_string(todo.id)
            :li: class=item
                :button: class={{ mark(todo.done) }} on:click=Msg.Toggle(string_to_int(key, 0))
                {{ todo.label }}
                :!button:
            :!li:
        :!for:
    :!ul:

    :div: class=bx-row
        :button: class="bx-btn bx-btn-primary" child={"add one"} on:click=Msg.Add :!button:
    :!div:
:!div:
Form.sbmx running in a browser
Form.sbmx `on:submit`, `on:input`, and a field the view drives
:props: model: Model
:!props:

:div: class=bx-card
    :div: class=bx-spread
        :h2: class=bx-title child={"Save a draft"} :!h2:
        :span: class="bx-badge bx-badge-quiet" child={to_string(model.hits) + " saves"} :!span:
    :!div:

    :hr: class=bx-divider :!hr:

    :form: on:submit=Msg.Save
        :div: class=field
            :label: class=bx-label child={"What are you writing?"} :!label:
            :input: class=bx-input value={{ model.draft }} on:input=Msg.Typed(value) :!input:
        :!div:
        :button: class="bx-btn bx-btn-primary" child=save :!button:
    :!form:

    :p: class=bx-muted
    draft: {{ model.draft }} — last saved: {{ model.saved }}
    :!p:
:!div:
Hero.sbmx running in a browser
Hero.sbmx the landing page's component: a keyed `:for:`, `===style.local`, a pseudo-element checkbox
:props: model: Model
:!props:

:section: class=card
    # Today
    :p: child={to_string(model.left) + " left"} :!p:
    :for: task in model.tasks key to_string(task.id)
        :button: class={{ mark(task.done) }} on:click=Msg.Toggle(string_to_int(key, 0))
            {{ task.label }}
        :!button:
    :!for:
:!section:
Showcase.sbmx running in a browser
Showcase.sbmx the biggest one — every block form on one page
:props: model: Model
:!props:

:section: class=card

:div: class=top

# Today

:p:
:span: class=count
{{ to_string(open_count(model.tasks)) }} open
:!span:
of {{ to_string(len(model.tasks)) }} — written as a document, compiled to WebAssembly
:!p:

:!div:

:div: class=row-add

:input: value={{ model.draft }} placeholder="What needs doing?" on:input=Msg.Draft(value)
:!input:

:button: class=add on:click=Msg.Add
Add
:!button:

:!div:

:div: class=tabs

:button: class={{ tab_class(model, "all") }} on:click=Msg.Show("all")
All
:!button:

:button: class={{ tab_class(model, "open") }} on:click=Msg.Show("open")
Open
:!button:

:button: class={{ tab_class(model, "done") }} on:click=Msg.Show("done")
Done
:!button:

:!div:

:if: len(shown(model)) == 0
    :p: class=empty child={"Nothing here. Add something above."} :!p:
:!if:

:ul: class=list

:for: task in shown(model) key to_string(task.id)

:li: class=item

:button: class={{ pick_class(task.done) }} on:click=Msg.Toggle(string_to_int(key, 0))

:span: class=box
:!span:

:span: class=text
{{ task.label }}
:!span:

:!button:

:!li:

:!for:

:!ul:

:div: class=foot

:span:
{{ to_string(open_count(model.tasks)) }} left
:!span:

:button: class=clear on:click=Msg.Clear
Clear done
:!button:

:!div:

:!section:
Served.sbmx running in a browser
Served.sbmx `load(request)`, so the page is rendered where the data is
:props: model: Model
:!props:

# Hello {{ model.user }}

:p:
{{ to_string(model.unread) }} unread
:!p:

:button: on:click=Msg.Clear
clear
:!button:
Feed.sbmx running in a browser
Feed.sbmx fetching, polling with `after`, and a subscription that stops itself
:props: model: Model
:!props:

# Feed — {{ to_string(model.items) }} items

:button: on:click=Msg.Refresh
refresh
:!button:

:button: on:click=Msg.TogglePolling
toggle polling
:!button:
Poster.sbmx running in a browser
Poster.sbmx `StarCmd.Send` — saving to a server that enforces CSRF
:props: model: Model
:!props:

# Status — {{ model.status }}

:button: on:click=Msg.Save
save
:!button:
Echo.sbmx running in a browser
Echo.sbmx what every event kind gives you as `value`, one element per channel
:props: model: Model
:!props:

# Last — {{ model.last }}

:button: class=tap on:click=Msg.Got(value)
tap
:!button:

:input: type=text class=typed on:input=Msg.Got(value)
:!input:

:div: class=wheel on:wheel=Msg.Got(value)
wheel here
:!div:

:div: class=keys on:keydown=Msg.Got(value)
type here
:!div:
Rows.sbmx running in a browser
Rows.sbmx a keyed list whose rows hold focus, reordered while you type in one
:props: model: Model
:!props:

:div: class=bx-card
    :div: class=bx-spread
        :div:
            :h2: class=bx-title child={"Reorder while you type"} :!h2:
            :p: class=bx-muted
            Type in a row, then move it. The caret follows the ROW, because the key says which row it is.
            :!p:
        :!div:
        :span: class="bx-badge bx-badge-quiet" child={to_string(model.moves) + " moves"} :!span:
    :!div:

    :hr: class=bx-divider :!hr:

    :for: row in model.rows key to_string(row.id)
        :div: class=row
            :input: class="bx-input field" value={{ row.text }} on:input=Msg.Typed(string_to_int(key, 0), value)
            :!input:
            :button: class="bx-btn lift" child={"move up"} on:click=Msg.Lift(string_to_int(key, 0)) :!button:
        :!div:
    :!for:
:!div:
Grid.sbmx running in a browser
Grid.sbmx a **nested `:for:`** — a loop inside a loop, and which key wins
:props: model: Model
:!props:

:div: class=bx-card
    :div: class=bx-spread
        :div:
            :h2: class=bx-title child={"A loop inside a loop"} :!h2:
            :p: class=bx-muted child={"64 squares from two nested :for: blocks"} :!p:
        :!div:
        :span: class=bx-badge child={to_string(model.lit) + " lit"} :!span:
    :!div:

    :hr: class=bx-divider :!hr:

    :div: class=board
        :for: row in model.rows key to_string(row.y)
            :div: class=rank
                :for: cell in row.cells key to_string(cell.id)
                    :button: class={{ shade(cell) }} on:click=Msg.Toggle(string_to_int(key, 0)) :!button:
                :!for:
            :!div:
        :!for:
    :!div:
:!div:
Choices.sbmx running in a browser
Choices.sbmx every form control: select, radio, checkbox, textarea
:props: model: Model
:!props:

:div: class=bx-card
    :div: class=bx-spread
        :h2: class=bx-title child=Preferences :!h2:
        :span: class="bx-badge bx-badge-quiet" child={summary(model)} :!span:
    :!div:

    :hr: class=bx-divider :!hr:

    :div: class=field
        :label: class=bx-label child=Size :!label:
        :select: class=bx-select on:change=Msg.Size(value)
            :option: value=small selected={{ model.size == "small" }} child=Small :!option:
            :option: value=medium selected={{ model.size == "medium" }} child=Medium :!option:
            :option: value=large selected={{ model.size == "large" }} child=Large :!option:
        :!select:
    :!div:

    :fieldset: class="bx-fieldset field"
        :legend: child=Tone :!legend:
        :div: class=tones
            :label: class=bx-check
                :input: type=radio name=tone value=warm checked={{ model.tone == "warm" }} on:change=Msg.Tone(value) :!input:
                :span: child=Warm :!span:
            :!label:
            :label: class=bx-check
                :input: type=radio name=tone value=cool checked={{ model.tone == "cool" }} on:change=Msg.Tone(value) :!input:
                :span: child=Cool :!span:
            :!label:
        :!div:
    :!fieldset:

    :div: class=field
        :label: class=bx-check
            :input: type=checkbox checked={{ model.agree }} on:change=Msg.Agree(value) :!input:
            :span: child={"I agree to the terms"} :!span:
        :!label:
    :!div:

    :div: class=field
        :label: class=bx-label child=Notes :!label:
        :textarea: class=bx-area on:input=Msg.Note(value)
        {{ model.note }}
        :!textarea:
    :!div:
:!div:
Notes.sbmx running in a browser
Notes.sbmx `Store`, `Load` and `Focus` — the browser's own storage, and a caret
:props: model: Model
:!props:

:div: class=bx-card
    :div: class=bx-spread
        :h2: class=bx-title child={"Draft"} :!h2:
        :span: class="bx-badge bx-badge-quiet" child={model.status} :!span:
    :!div:

    :hr: class=bx-divider :!hr:

    :textarea: class="bx-area draft" on:input=Msg.Typed(value)
    {{ model.text }}
    :!textarea:

    :div: class=bar
        :button: class="bx-btn bx-btn-primary" child=save on:click=Msg.Save :!button:
        :button: class=bx-btn child=restore on:click=Msg.Restore :!button:
        :button: class="bx-btn bx-btn-quiet" child=clear on:click=Msg.Clear :!button:
    :!div:

    :p: class=bx-muted
    It survives a reload — `Store` and `Load` are the browser's own storage, and `clear` puts the caret
    back in the field.
    :!p:
:!div:
Clock.sbmx running in a browser
Clock.sbmx a `pure` view cannot read a clock, so a tick asks the server
:props: model: Model
:!props:

:div: class=bx-card
    :div: class=bx-spread
        :div:
            :h2: class=bx-title child={"Server time"} :!h2:
            :p: class=bx-muted child={to_string(model.ticks) + " ticks, each one asked of the server"} :!p:
        :!div:
        :button: class="bx-btn bx-btn-primary" child={button_text(model)} on:click=Msg.Toggle :!button:
    :!div:

    :hr: class=bx-divider :!hr:

    :p: class=face child={label(model)} :!p:
    :p: class=bx-muted
    A `pure` view cannot read a clock, and a tick carries no payload — so the time comes from where time
    lives.
    :!p:
:!div:
Snake.sbmx running in a browser
Snake.sbmx a game: a tick, four global keys, a list that moves every frame
:props: model: Model
:!props:

:div: class=bx-card
    :div: class=bx-spread
        :div:
            :h2: class=bx-title child={"Snake"} :!h2:
            :p: class=bx-muted child={"Arrow keys. A tick, four global keys, and a list that moves every frame."} :!p:
        :!div:
        :span: class=bx-badge child={verdict(model)} :!span:
    :!div:

    :hr: class=bx-divider :!hr:

    :div: class=board
        :for: seg in model.body key to_string(seg.x) + "-" + to_string(seg.y)
            :div: class=seg style={{ at(seg) }} :!div:
        :!for:
        :div: class=food style={{ at(model.food) }} :!div:
    :!div:

    :div: class=bx-row
        :button: class="bx-btn bx-btn-primary" child={"new game"} on:click=Msg.Again :!button:
    :!div:
:!div:
Cart.sbmx running in a browser
Cart.sbmx money: `Decimal<2>`, a percent literal, and `money_split` placing the odd penny
:props: model: Model
:!props:

:div: class=bx-card
    :div: class=bx-spread
        :h2: class=bx-title child=Order :!h2:
        :span: class=bx-badge child={to_string(len(model.lines)) + " lines"} :!span:
    :!div:

    :for: line in model.lines key to_string(line.id)
        :div: class=line
            :button: class="bx-btn bx-btn-icon" child={"−"} on:click=Msg.Fewer(string_to_int(key, 0)) :!button:
            :button: class="bx-btn bx-btn-icon" child={"+"} on:click=Msg.More(string_to_int(key, 0)) :!button:
            :div:
                :div: class=name child={line.name} :!div:
                :div: class=each child={money(line.each) + " each × " + to_string(line.count)} :!div:
            :!div:
            :div: class=amount child={money(line_total(line))} :!div:
        :!div:
    :!for:

    :hr: class=bx-divider :!hr:

    :div: class=sum
        :span: class=bx-muted child={"subtotal " + money(subtotal(model.lines)) + "  ·  service 12.5% " + money(service(model.lines))} :!span:
        :span: class="bx-figure amount" child={money(cart_total(model.lines))} :!span:
    :!div:
:!div:

:div: class=bx-card
    :h2: class=bx-title child={"Split the bill"} :!h2:
    :p: class=bx-muted
    Dividing money cannot lose a penny, so the shares are computed rather than rounded.
    :!p:
    :div: class=bx-row
        :button: class=bx-btn child={"one bill"} on:click=Msg.Split(1) :!button:
        :button: class=bx-btn child={"two ways"} on:click=Msg.Split(2) :!button:
        :button: class=bx-btn child={"three ways"} on:click=Msg.Split(3) :!button:
    :!div:
    :p: class="shares bx-mono" child={shares(model)} :!p:
:!div:
Board.sbmx running in a browser
Board.sbmx noughts and crosses: rules as a pure function, a winner derived rather than trusted
:props: model: Model
:!props:

:div: class=bx-card
    :div: class=bx-spread
        :h2: class=bx-title child={"Noughts and crosses"} :!h2:
        :span: class=bx-badge child={verdict(model)} :!span:
    :!div:

    :hr: class=bx-divider :!hr:

    :div: class=grid
        :for: square in model.squares key to_string(square.id)
            :button: class={{ face(square) }} child={square.mark} on:click=Msg.Play(string_to_int(key, 0)) :!button:
        :!for:
    :!div:

    :div: class=bx-row
        :button: class=bx-btn child={"new game"} on:click=Msg.Again :!button:
    :!div:
:!div:

What these are

Each one is in the collection for a reason — an edge, a combination nothing else covers, or a case that was silently wrong until somebody wrote it down. Most of them found a defect when they were written, and examples/README.md says which.

Nothing here is a mock-up. tools/gallery.mjs builds each component, serves it, mounts the real .wasm in a real browser and screenshots the result — and then reads the page’s own DOM back to check the state it was given actually took. A screenshot is the one kind of claim that cannot go stale loudly: the picture keeps looking correct after the component stops working, because a picture is a file.

The styling is two halves, and that is the same split Tailwind and Bootstrap make. A component’s ===style.local holds what makes it itself — a board is 3.6rem squares, a snake segment is 18px, and star scopes those rules to that component. Everything shared — type, colour, spacing, the shape of a button — belongs to the page, and here it is one 166-line stylesheet. star never reads a class name, so swapping that file for Tailwind’s output changes nothing about how any of this works.