Start
Getting started
Make a small app with three files. Run it in a browser and in a desktop window.This page as MarkdownStatuscurrent on 2026-11-06, for version 0.
Related documentslanguage spec, component reference, standard library, limits of version 0
This guide makes a small shopping list app. The app has three files: a module file with a record type, a component for one row, and the root component. At the end, the same app runs in a browser and in a desktop window.A component file has two parts. A line --- divides them.A file without --- and markup is a module file. It has types and functions.The line breaks change nothing: a utility ends at a space or at the end of a line.The compiler checks all files before it builds the app. Change item.name in ItemRow.surf to item.title, and run this command:The command shows the file, the line, the column and the message of each error. The development server shows the same errors in the page. Change the name back.surf lint finds code that compiles but is probably wrong. Examples are a state that nothing reads, and a #for over records with no key:The editor extensions in editors/vscode and editors/zed show the errors and the warnings while you type. They use surf lsp.surf test runs tests with no build, no browser and no window.
- Install the surf command. It is the only tool that you install, and a web build needs nothing else:
- bashcurl -fsSL https://git.solenovis.com/henning.liemant/surface/raw/branch/main/scripts/install.sh | sh
- Make a project with the name groceries:
- bashsurf new groceries
- Start the development server:
- bashsurf dev groceries
- Open http://localhost:8000 in a browser. The page shows a counter. The command also prints the address of the inspector, the dev tools of the running app.
surf
state count = 0;derived doubled = count * 2;---<Column style="p-6 gap-3"> <Text>Count: {count}</Text> <Text>Doubled: {doubled}</Text> <Button on:press={count += 1}>Add one</Button></Column>
- The part above the line has the declarations. state count = 0 is a value that can change. derived doubled gets its value from other values. It changes when count changes.
- The part below the line has the markup: the elements of the UI. {count} shows a value in text. on:press={count += 1} runs a statement each time the button gets a press.
- style has utilities: p-6 is a padding of 24 logical px, and gap-3 is a space of 12 logical px between the children. A logical px is the same size on each screen. The web output writes it in rem, so the font size of the browser scales the app.
surf
pub type Item = { id: Int, name: String, done: Bool = false }pub fn left(items: List<Item>) -> String { const n = items.filter(i => !i.done).count(); return n == 1 ? "1 item left" : "{n} items left";}
- pub makes a declaration available to other files. Other declarations are private to the file.
- type Item is a record type. done has a default value, so a new item can leave it out.
- items.filter(i => !i.done) makes a new list with the items that are not done. The standard library lists all methods.
surf
import { Item } from "./model.surfs";bindable prop item: Item;event remove(id: Int);---<Row style="gap-2 items-center"> <Toggle style="grow" bind:checked={item.done}>{item.name}</Toggle> <Button on:press={emit remove(item.id)}>Remove</Button></Row>
- import names a file relative to this file: ./ is the same folder and ../ the folder above. The files can be in subfolders of src.
- prop item: Item is a value that the parent gives. bindable lets the component write the value back to the parent.
- bind:checked={item.done} connects the check box to the field done. A click on the check box writes the field, and the write goes to the parent through the bindable prop.
- event remove(id: Int) declares an event. emit remove(item.id) sends it to the parent.
surf
import { Item, left } from "./model.surfs";import ItemRow from "./ItemRow.surf";state items: List<Item> = [];state draft = "";state nextId = 1;fn add() { const name = draft.trim(); if name.isEmpty() { return; } items.push({ id: nextId, name: name }); nextId += 1; draft = "";}fn remove(id: Int) { items = items.filter(i => i.id != id);}---<Column style="p-6 gap-3 max-w-120"> <Text style="text-24 font-600">Groceries</Text> <Row style="gap-2"> <Input style="grow" bind:value={draft} placeholder="Milk, bread, ..." on:submit={add} /> <Button on:press={add} disabled={draft.trim().isEmpty()}>Add</Button> </Row> #for item in items key item.id { <ItemRow bind:item={item} on:remove={remove} /> } empty { <Text style="text-$text-secondary">The list is empty.</Text> } <Text style="text-$text-secondary">{left(items)}</Text></Column>
- state items: List<Item> = [] needs the type, because an empty list does not show the type of its elements.
- bind:value={draft} connects the text field to draft. on:submit={add} calls add when the user presses Enter.
- #for item in items key item.id { ... } makes one ItemRow for each item. The key keeps the identity of a row when the list changes. The empty block shows when the list has no items.
- bind:item={item} gives each row its item, and the writes of the row change that item in items.
- on:remove={remove} calls remove with the id of the event.
- $text-secondary is a theme token. It has a value for each variant of the theme: light, dark, and two variants with high contrast. A token always has $. The layout spec, section 2.1, lists the tokens.
surf
<Button style="px-4 py-2 rounded-8 bg-$accent text-$on-accent font-600 hover:bg-$accent-hover focus-visible:ring-2 focus-visible:ring-$focus disabled:opacity-50 transition-background duration-200"> Save</Button>
bash
surf check groceries
bash
surf lint groceries
- Give three elements of groceries/src/App.surf a testid:
- surf<Input testid="draft" style="grow" bind:value={draft} placeholder="Milk, bread, ..." on:submit={add} /><Button testid="add" on:press={add} disabled={draft.trim().isEmpty()}>Add</Button><Text testid="left" style="text-$text-secondary">{left(items)}</Text>
- Make the file groceries/tests/groceries.surfs:
- surfimport { Test } from "surf:test";import { Item, left } from "~/model.surfs";pub fn testLeft() { Test.equal(left([{ id: 1, name: "Milk" }]), "1 item left");}pub fn testAdd() { Test.mount("App.surf"); Test.fill("draft", "Milk"); Test.press("add"); Test.equal(Test.text("left"), "1 item left");}
- Run the tests:
- bashsurf test groceries
- A test is a pub fn with a name that starts with test. It has no parameters, and it is not async.
- A test file is a module with a lowercase name. ~/model.surfs is a path from src.
- Test.mount("App.surf") runs the app with no window. Test.press and Test.fill send real input events to the element with that testid.
- Build a full app, section 8, lists all functions of Test.
- Stop the development server.
- Build the web app:
- bashsurf build groceries
- Open groceries/build/web/index.html in a browser. The page needs no server. You can copy the folder groceries/build/web to any static web host.
- Build the desktop app:
- bashsurf build groceries --target desktop
- The command writes the binary into the folder of the system of this machine, for example groceries/build/desktop/linux-x86_64/groceries on Linux, and groceries/build/desktop/macos-aarch64/groceries.app on a Mac. Each machine can also build Windows: --os windows --arch x86_64 writes groceries/build/desktop/windows-x86_64/groceries.exe (the surf command, section 3). The first native build downloads a private Rust toolchain and the runtime pack into ~/.surf, and it compiles the runtime, so it takes some minutes. Later builds take some seconds. A native build needs a linker: on macOS, run xcode-select --install, and on Linux, install the package gcc. surf doctor shows what the machine has.
- Start the binary in groceries/build/desktop/<system>. On macOS, on Windows, and on Linux with a Wayland compositor, the app opens in a window. The window has the same UI as the web page.
- Add the package:
- bashsurf add charts https://example.com/charts.git --rev v1.2.0
- Import it:
- surfimport { Bar } from "pkg:charts";
- The component reference lists the built-in components, their attributes and their events.
- The standard library lists the methods of String, List, Map and the other built-in types.
- The Surface Layout Spec, section 12, lists each utility of style.
- The language spec has all rules: slots, effects, async functions, and extern modules for code in JavaScript or Rust.
- The limits of version 0 lists the features that the compiler does not have yet.
- examples/todomvc is a larger app with a router and storage in extern modules.
- Build a full app adds pages from files, a server, forms, sign-in, tests and a deployment. examples/notes has its code, and examples/tickets is a larger app with an API.