Waterui logo

Waterui

Organization
water-rs
waterui

Build cross-platform native apps with the WaterUI Rust framework. Use this skill whenever writing, reviewing, or debugging WaterUI code — views, reactive state (Binding/Computed/signals), layout, styling, navigation, lists, forms, gestures, drag and drop, menus, windows, media, charts, maps, localization/i18n/RTL, animation, accessibility, tests — or when running the `water` CLI (`water create`, `water run`, `water preview`, `water build`, `water bench`). Trigger on any Rust file that imports `waterui`, any project containing a `Water.toml`, and any mention of WaterUI, `text!`, `vstack`/`hstack`, `Binding<T>`, `impl View`, or a `water` command, even when the framework is not named explicitly.

Overview

Publisherwater-rs
Repositorywaterui
Skill namewaterui
Stars
520
Forks
18
Bundled files
31
LicenseApache-2.0
Links
  • Markdown instructions

    A SKILL.md file the model loads on demand, so it only costs tokens when a request actually matches.

  • Works with any LLM

    AI skills are plain Markdown, not provider-specific code, so this works with GPT, Claude, Gemini, Grok, or a local model.

  • 31 bundled files

    Scripts, templates, and references the model can read while it works. Files are read-only and never executed.

  • Open source

    Published by water-rs on GitHub. Read the source before you install it.

Installation

Install the Waterui AI skill in TypingMind to use it with any LLM, or drop it into another agent that reads SKILL.md.

1

Install in TypingMind

TypingMind installs a skill straight from its GitHub folder — it reads SKILL.md, bundles the resource files, and stores the result locally.

  1. Open the app and go to Plugins → Skills.
  2. Choose "Install from GitHub".
  3. Paste the skill folder URL below and confirm.
    https://github.com/water-rs/waterui/tree/dev/.claude/skills/waterui
  4. Enable the skill in any chat where you want it available.
Plugins → Skills → Add skill → From GitHub URL, then paste the folder URL and press Continue.
2

Install in another agent

Any agent that reads the Agent Skills format can use this skill — copy the folder into that agent's skills directory.

Claude Code — .claude/skills
git clone --depth 1 https://github.com/water-rs/waterui.git /tmp/waterui
mkdir -p .claude/skills
cp -r /tmp/waterui/.claude/skills/waterui .claude/skills/waterui
Restart Claude Code after copying so it picks up the new skill.

Use it in TypingMind

Enable Waterui in any TypingMind chat and the model takes it from there. Its name and description sit in the system prompt, and the moment a request matches, the model loads the full instructions itself — you never invoke it by hand, and it costs no tokens until it is actually used.

The model loads Waterui on its own as soon as a request matches it.

Works with any AI model

AI skills are plain Markdown instructions rather than provider-specific code, so Waterui is not tied to the model it was written for. Install it once in TypingMind and use it with GPT-5, Claude, Gemini, Grok, DeepSeek, Mistral, Llama, or a local model you run yourself — all on your own API keys.

  • Loaded only when it is needed

    The system prompt carries just the name and description. The instructions are fetched on the first matching request, so an idle skill costs nothing.

  • Switch models mid-chat

    Because the skill is instructions rather than code, changing model does not break it — the next model reads the same SKILL.md.

Skill instructions

This is the SKILL.md content the model loads. Read it before installing — a skill is instructions your model will follow.

Building apps with WaterUI

WaterUI is a Rust UI framework that renders to real native widgets (UIKit/AppKit, GTK4) or to its own GPU renderer — Hydrolysis, which is also the Android backend — from one view tree. It is fine-grained reactive: a value change updates exactly the widget that reads it, without rebuilding the surrounding tree.

Almost every mistake in WaterUI code comes from writing it as if it were React or SwiftUI. The five rules below are what actually differ. Read them before writing code.

Reference map

Read the file that matches the task. Each is self-contained; none reference each other beyond pointers.

TopicFile
Signals, Binding, Computed, collections, async tasks, animation, conditionalsreferences/reactivity.md
Component catalog with real signatures: layout, controls, menus, text, lists, forms, overlaysreferences/components.md
Photos, video, media picking, web views + JS bridge, shaders, particles, charts, mapsreferences/media.md
Gestures, taps, hover, cursor, drag & dropreferences/interaction.md
Tabs, navigation stacks, toolbars, transitions, split views, windowsreferences/navigation.md
Colors, theme tokens, dark mode, icons, shapes, gradients, Material 3references/styling.md
Translations, plurals, locale switching, formatting, RTLreferences/i18n.md
#[waterui::test], #[waterui::bench], #[preview], snapshotsreferences/testing.md
water mcp tool surface, animation stepping, agent drive loopreferences/mcp.md
water CLI, Water.toml, Cargo features, assets, permissions, platforms, embeddedreferences/project.md
Compile errors, silent bugs, and their fixesreferences/troubleshooting.md

When an API is still unclear, the compiled examples in examples/*/src/lib.rs of the WaterUI repository are ground truth — they are built in CI, so they are never stale. The prose companion to this skill is the book at https://book.waterui.dev, which goes deeper on the topics here and covers ones this skill does not (plugins, resolvers and hooks, shaders, error handling, library authoring). Each book release is pinned to an exact WaterUI commit, shown in the book itself — check that pin against the version you depend on before copying from it.

The five rules

1. Pass the signal, never a snapshot of it

Reactive APIs take impl IntoComputed<T>, impl IntoSignalF32, or &Binding<T>. Handing them .snapshot() reads the value once and freezes it — the UI then never updates, and nothing fails at compile time, so this bug is silent.

rust
view.opacity(fade.clone())              // reacts
view.opacity(fade.snapshot())                // frozen forever — a plain f32
Photo::new(url).blur(blur.clone())      // reacts
text!("Count: {count}")                 // reacts

.snapshot() belongs inside event handlers and .map() closures, where you genuinely want the value at that instant. It does not belong in a view body.

2. watch is not the reactive primitive — it is the escape hatch

watch(signal, |v| ...) tears down and rebuilds its entire subtree on every change, so any state living inside that subtree is destroyed. Three things replace nearly every use:

rust
text!("{status}")                               // reactive text — not watch + format!
Photo::new(url).blur(blur.clone())              // reactive value — pass the signal
Lazy::for_each(rows.clone(), row_view)          // dynamic set of views — a collection

Reach for watch only for a genuinely one-off structural swap where no signal-aware API and no collection applies. Check those three first, every time.

3. Inject handler state with .state(), do not capture clones

.action() takes a handler: a function whose parameters are extractors resolved from the environment. .state(&value) puts a value in that environment; an extractor pulls it out. State<T> is the wrapper for a type the app does not own — Binding, Rc, a third-party value; a Clone type the app does own gets #[state] once and is then written bare. This keeps handlers as plain named functions instead of a thicket of move closures. The same machinery drives every callback in the framework — .on_tap, gestures, drops, menu commands, list edits — not just buttons.

rust
button("Increment")
    .action(|State(count): State<Binding<i32>>| *count.get_mut() += 1)
    .state(&count)

Repeated parameters of the same state type bind positionally — bare #[state] types and State<T> spellings share one sequence: the first .state() call feeds the first parameter of that type.

rust
button("Search")
    .action(|State(q): State<Binding<Str>>, State(hist): State<Binding<Vec<Str>>>| {
        hist.with_mut(|h| h.push(q.snapshot()));
    })
    .state(&query)      // -> first parameter
    .state(&history)    // -> second parameter

Beyond two or three pieces of state, stop threading them individually. Put them in one Clone struct, inject it once on a container, and write handlers as free functions. This is the idiomatic shape for a real screen:

rust
#[state]
#[derive(Clone)]
struct Editor {
    rows: ReactiveList<Row>,
    editing: Binding<bool>,
}

fn toggle_editing(state: Editor) {
    state.editing.toggle();
}

fn content(state: Editor) -> impl View {
    vstack((
        button("Edit").action(toggle_editing),
        List::for_each(state.rows.clone(), row_view),
    ))
    .state(&state)          // injected once, visible to every handler below
}

Async is the same shape: .action_async(|State(x): State<Binding<Str>>| async move { … }).

4. A changing set of views is a collection, not a watch

ForEach/List diff by Identifiable id, so inserting one row touches one row. watch over a Vec rebuilds everything and can escalate to a full-window rebuild.

rust
use waterui::Identifiable;                             // the derive is NOT in the prelude
use waterui::component::lazy::Lazy;
use waterui::reactive::collection::List as ReactiveList;

#[derive(Clone, Identifiable)]
struct Row { #[id] id: u64, title: Str }

let rows = ReactiveList::from(seed_rows);              // bulk-seed; .push/.insert/.remove diff by id

Lazy::for_each(rows.clone(), |row| text(row.title))        // reactive sequence in a stack
List::for_each(rows.clone(), |row| ListItem::new(...))     // platform list: lazy, editable

Note the shape: ForEach is a collection of views, not a view. A container consumes it — Lazy::for_each(data, f) is the shorthand for Lazy::vstack(ForEach::new(data, f)), and List::for_each is the list-control equivalent. Writing ForEach::new(..) where a view is expected is a trait-bound error, not a runtime surprise.

List realizes only the visible window — it handles 100,000 rows, including after a programmatic jump. Use it whenever the data is a list of rows; use the Lazy stacks when you just need a reactive sequence inside your own layout. A derived row set (filtered or sorted from other state) is still a collection: wrap the derived signal in SignalCollection rather than watching a Vec.

5. Rebuild-driven state loss is correct behavior, not a bug to patch

A component's body may be expensive and may do one-time setup. When a parent's control flow (when, watch, a route change) reconstructs a component, that instance is gone and a new one initializes — losing its internal state is the intended semantics.

If some state must survive, that state was owned at the wrong level: lift it into a Binding held by the parent and pass it down. Never try to preserve it with hidden caches, hook-like slots, or position-keyed storage. Those do not exist in WaterUI and adding them is an architectural error.

Quick start

rust
use waterui::app::App;
use waterui::prelude::*;

fn counter() -> impl View {
    let count = Binding::i32(0);

    vstack((
        text!("Count: {count}").headline(),
        button("+1")
            .action(|State(count): State<Binding<i32>>| *count.get_mut() += 1)
            .state(&count),
    ))
    .spacing(8.0)
    .padding()
}

pub fn app(env: Environment) -> App {
    App::new(counter, env)
}

use waterui::prelude::*; brings in views, layout, colors, text, controls, navigation, menus, State, Str, AnyView, SignalExt, and AnimationExt. Several everyday names live outside it:

rust
use waterui::Identifiable;                             // the derive macro
use waterui::reactive::binding;                        // the general Binding constructor
use waterui::reactive::collection::List as ReactiveList;
use waterui::widget::condition::when;                  // conditionals
use waterui::animation::Animation;                     // animation curves
use waterui::component::lazy::Lazy;                    // reactive stacks over a collection
use waterui::views::ForEach;                           // the collection itself
use waterui::gesture::{DragGesture, LongPressGesture, TapGesture};
use waterui::cursor::CursorStyle;
use waterui::drag_drop::{Files, Transferable};
use waterui::env::with;                                // scope a value to a subtree
use waterui::task::{sleep, spawn_local};               // async utilities

Components behind cargo features are also absent until you enable them. waterui's defaults are gpu, assets, media, inspector, snackbar; webview and flow-markdown are opt-in in Cargo.toml. Canvas, charts, barcodes and particles have opt-in facade features canvas, chart, barcode and particle, exposing the independent crates as waterui::canvas, waterui::chart, waterui::barcode and waterui::particle. Direct dependencies such as waterui-chart remain valid and expose the same types as the facade, including with dynamic linking. Maps and Mermaid diagrams use direct dependencies on waterui-map and waterui-mermaid; waterui-mermaid lives in its own repository (water-rs/mermaid) and is a git dependency until it can be published. Mermaid additionally needs one call at the root of the app: a ```mermaid fence in Markdown stays plain code until the environment has waterui_mermaid::install(&mut env) (do it inside use_env at the root view, as the component repository's examples/markdown shows, so water preview gets it too). Keep the generated dev = ["waterui/dynamic_linking"] feature — it is what makes water preview and the fast dev loop link dynamically (references/project.md).

Core building blocks

Views

Any function returning impl View is a view — no wrapper type, no trait to implement. &'static str, String, and Str are views too, so a bare literal is valid content.

rust
fn card(title: &'static str) -> impl View {
    vstack((text(title).title(), Divider))
}

vstack((card("Hello"), card("World"), "a bare literal is a view"))

Str is WaterUI's string type: cheap to clone (a static reference or a ref-counted buffer), derefs to str, and converts from both directions — Str::from_static("lit") for literals, format!(..).into() for runtime text. It is in the prelude and is what Binding<Str> text fields hold.

Views are 'static, so a borrowed &str parameter will not compile. Take &'static str, Str, or impl IntoText instead — the last is the friendliest and is what the built-in constructors accept. (A helper that only reads a &T while building may keep the reference parameter and return impl View + use<>.)

When a component needs to read the environment or be stored as a value, implement the trait directly — body takes self by value:

rust
struct ColorSwatch { color: Binding<Color> }

impl View for ColorSwatch {
    fn body(self, _env: &Environment) -> impl View {
        signal_color(self.color).size(64.0, 32.0)   // a Color is itself a view
    }
}

AnyView is the erased view type — .anyview() or AnyView::new(v) — for match arms, struct fields, and heterogeneous collect()s.

State

rust
let count = Binding::i32(0);                    // bool f32 f64 i32 i64 isize u32 u64 usize
let flag  = Binding::bool(false);
let name  = Binding::container(String::new());  // any Clone type
let sel: Binding<Option<Selected>> = Binding::default();   // empty optional

let pane: Binding<Pane> = binding(Pane::Inbox); // general form, needs an inferable type
let settings = Settings::binding();             // #[form] types: inference-free

There is no Binding::new. Prefer the typed constructors: binding(v) takes impl Into<T>, so T is frequently ambiguous and you get "type annotations needed" unless something downstream pins it (an annotation, a turbofish, a struct field, or a control like toggle/slider that takes &Binding<bool> / &Binding<f64>).

Pass bindings to child views by reference (&Binding<T>); clone only when a value must be owned by a closure or a modifier.

Text

text() for static strings, text! for anything reactive, interpolated, or plural. Both localize: text("Settings") resolves through the TranslationCatalog installed in the environment at runtime, while text! embeds the translations at compile time — the whole literal is a translation-catalog key, and its placeholder names are slot keys, bare identifiers aliased with name = expr when the local has a different name (references/i18n.md).

rust
text("Settings").title()                        // title/headline/sub_headline/body/caption/footnote
text!("Count: {count}")                         // updates automatically
text!("{unread} unread", unread = mail.count()) // aliasing an expression into a slot
text!("Blur: {blur:.1}")                        // format specs work
text("0x1F60").monospaced()                     // the platform's fixed-pitch face, same size

Import the macro and write bare text! — never waterui::text!. The same goes for every name, inside macro bodies too: bring it into scope with use and write ForEach, not ::waterui_core::views::ForEach. On a Text, .size(..) is the font size (and shadows the two-argument frame .size(w, h)). .monospaced() is a design, not a family: it keeps the slot and size and each backend picks its own monospaced face, so never spell a font name for code.

Layout

rust
hstack((a, b, c)).spacing(8.0)
vstack((a, b)).leading().padding()
zstack((background, content))
scroll(content)
spacer()                    // flexible gap
spacer().height(16.0)       // fixed gap

let buttons: HStack<_> = items.iter().map(|i| button(i.label)).collect();

A fixed, known set of children is a tuple — vstack((a, b, c)). Reach for vec! only when the length is genuinely runtime-dependent, and for a changing set use rule 4. A layer that must fill the window (overlays, HUDs) uses absolute((..)), not a content-sized zstack (references/components.md).

Conditionals

rust
use waterui::widget::condition::when;   // not in the prelude

// A plain bool: Option<impl View> is itself a View.
row.flagged.then(|| flag_icon())

// A reactive bool: use when(...), or keep the view and drive .visible(..).
when(logged_in.clone(), || dashboard()).otherwise(|| login())
when(state.equal_to(0), || loading())
    .or(state.equal_to(1), || ready())
    .otherwise(|| error())

new_marker().visible(is_new.clone())        // keep the view, drive its visibility

The distinction matters: Option<impl View> is a view, but a signal of Option<impl View> is not, so flag.map(|b| b.then(|| view)) on a Binding does not compile. Reach for when or .visible there.

For many branches over a plain (non-reactive) value, a match returning .anyview() is clearer than a long when chain.

Modifiers

rust
.padding() / .padding_with(16.0) / .padding_with((8.0, 16.0)) / .padding_horizontal(16.0)
.background(color) / .foreground(color) / .overlay(view)
.size(w, h) / .width(w) / .height(h) / .min_width(w) / .max_width(w) / .min_size(..) / .max_size(..)
.scale(x, y) / .rotation(degrees) / .offset(x, y)     // two arguments, not one
.border(color, width) / .shadow(shadow) / .clip(shape)
.opacity(signal) / .visible(signal) / .disabled(signal)
.blur(signal) / .brightness(..) / .contrast(..) / .saturation(..) / .grayscale(..) / .hue_rotation(..)
.a11y_label(..) / .a11y_value(..) / .a11y_id("settings.wifi") / .a11y_role(..)
.on_appear(..) / .on_change(&signal, ..) / .on_tap(..) / .gesture(g, handler) / .context_menu(items)
.cursor(style) / .ignore_safe_area(EdgeSet::ALL) / .floating()
.ignore_safe_area(SafeAreaRegions::KEYBOARD.on(EdgeSet::BOTTOM))   // regions × edges

Visual modifiers take signals — pass bindings straight in; the filter row works on any view, not just images. Plain-size frame modifiers (.size, .width, .min_width) take f32; the max_* pair also accepts signals.

The Environment

Environment is a type-indexed container that flows down the view tree: the type is the key, so there is no registration step and no string names. It carries the theme, the locale, and any service your app installs, which is how a deeply nested button reaches shared configuration without every intermediate function taking it as a parameter.

rust
use waterui::env::{use_env, with};
use waterui::impl_extractor;

#[derive(Clone)]
struct ApiClient { base_url: Str }
impl_extractor!(ApiClient);                   // makes it a handler/`use_env` parameter

// Seeding, usually in `app(env)`:
env.insert(client);                            // in place
env.with(client);                              // in place, chains
let scoped = env.extending(client);            // non-mutating overlay
with(subtree_view, LayoutDirection::RightToLeft)   // free fn: scope a value to one subtree

// Reading, from a view:
use_env(|client: ApiClient| text!("API: {url}", url = Binding::container(client.base_url)))

// Reading, from a handler — same extractors, no ceremony:
button("Send").action(|client: ApiClient| send(&client))

// Reading, where absence is legitimate — the non-panicking form:
let locale = env.get::<Locale>().cloned().unwrap_or_else(|| locales::EN.clone());

Inserting the same type twice replaces the earlier value; Store<K, V> pairs a value with a marker type when one type genuinely needs several roles. .install(plugin) scopes a value to a subtree rather than the whole app.

Extraction is fast-fail: a missing type panics with a message naming it. That is deliberate — wrap the parameter in Option<T> (or use env.get) where absence is legitimate. Theme tokens are the one absence that cannot happen: the runtimes install the framework's default colour and font set themselves, and a style's install_tokens layers over them at mount, so a hand-built Environment::new() resolves every Font and colour slot without an install step.

.state(&value) from rule 3 is the same machinery with a narrower scope: it installs into the environment of one view, and a State<T> or #[state]-marked parameter reads it back.

Accessibility is part of construction

Every control takes a mandatory label: button("Save"), toggle("Wi-Fi", &on), slider("Volume", &level), Picker::new("Sort", items, &choice). (The one exception: progress(value).label("Downloading") takes its label as a modifier.) This is not optional decoration — it drives screen readers and it is what waterui-testing queries. To hide a label visually while keeping it in the tree, use the display-mode API, never drop the label: .hide_label() on any labeled control, .label_style( LabelDisplayMode::IconOnly) for icon buttons, .install(LabelDisplayMode::IconOnly) for a whole toolbar.

Component index

Look up exact signatures in references/components.md; media, web, graphics, and data rows live in references/media.md.

CategoryComponents
Layouthstack vstack zstack scroll spacer grid absolute overlay Divider
Controlsbutton toggle slider stepper field/TextField progress Picker
MenusMenu Command .context_menu Shortcut
Interaction.on_tap .gesture TapGesture LongPressGesture DragGesture .draggable .drop_destination .on_hover_enter .cursor
Texttext text! styled Code RichText include_markdown! FlowMarkdown
CollectionsList ListItem ForEach SignalCollection ScrollController
NavigationTabs Tab NavigationStack NavigationLink NavigationSplitView Window
Forms#[form] form() DatePicker Calendar ColorPicker FilePicker
OverlaysSnackbar SnackbarManager FullScreenOverlayManager Card suspense .anchored_overlay AnchoredOverlay .dialog Dialog DialogAction
MediaPhoto Image VideoPlayer PlaybackSession MediaPicker
DataChart (12 kinds) Map
GraphicsCanvas Barcode::qr() Svg shader! ParticleSystem GpuContentView ExternalFrameView icon sets
PlatformWebView #[js_api] waterui-chromium

Verify before declaring done

WaterUI has a fast headless feedback loop; use it instead of reasoning about whether a view renders. Neither of these needs a device.

bash
water preview my_view --backend hydrolysis --theme material3 --output preview.png
cargo nextest run -p my-app

#[preview] fn my_view() -> impl View renders a view to PNG. #[waterui::test] drives the real accessibility tree with taps and assertions — it is simultaneously an interaction test and an accessibility check, which is why a component that cannot be tested this way is a bug rather than a gap. Details in references/testing.md.

Backend selection uses [platforms.<platform>] backend = "..." in Water.toml; --backend may confirm, but not contradict, that declaration. Without either, the platform default applies. See platforms and backends, including the Hydrolysis requirement for Linux previews.

Driving the app from an agent

water mcp serves the running app to an agent over MCP: run it in the project root and it builds the app headless, then exposes the live accessibility tree, actions, and screenshots as tools. The server answers initialize and tools/list immediately, so a cold first build does not stall the client handshake — tool calls simply wait for the build.

water create writes a project-level .mcp.json that points MCP clients at it:

json
{
  "mcpServers": {
    "app": {
      "command": "water",
      "args": ["mcp"]
    }
  }
}

For clients that do not read .mcp.json, register the same server by hand. Codex, in ~/.codex/config.toml:

toml
[mcp_servers.app]
command = "water"
args = ["mcp"]

Cursor, in .cursor/mcp.json, uses the same shape as the generated file above.

Once connected, the loop is: snapshot to read the tree, act (or pointer / key / type_text) by node id — every mutating tool returns the settled tree, so no follow-up snapshot is needed — screenshot when layout or appearance matters, wait for conditions instead of polling, then edit the source and restart to rebuild and relaunch with the changes. Node ids are stable across turns within a session. The full tool surface — state-filtered selectors, element-anchored pointer input, settle: false + advance for stepping through animations frame by frame — is in references/mcp.md.

preview renders a #[preview] function or an expr expression (e.g. text("hi")) and returns the PNG image content directly — use it to check one component without driving the whole app.

TypeScript views

A view can be authored in TypeScript — JSX over the same component catalog — and mounted from Rust with tsx!, which returns an ordinary View that slots anywhere a view goes. The application crate needs waterui's opt-in ts feature. The authoring guide is docs/jsx.md; the rules that differ from ordinary Rust views:

  • The path is relative to the Rust file that mounts the module, and the module id the bundle publishes is that file's path relative to the crate root, extension kept — tsx!("./promo.tsx", …) in src/lib.rs mounts "src/promo.tsx". The macro stats the file at compile time but never parses it; the water CLI bundles the modules the binary mounts.
  • Props are a struct literal naming a #[derive(TsProps)] type; the Rust struct and the TypeScript interface are checked against each other by contract hash, so a bundle from another build is refused rather than mis-mounted. String ⇄ string, Binding<T> ⇄ Signal<T>, Box<dyn Fn()> ⇄ () => void, and #[ts(rename = "onDismiss")] maps a snake_case field to its camelCase prop. A module that takes no props is tsx!("./about.tsx"), typed against NoProps.
rust
use waterui::ts::schema::TsProps;
use waterui::tsx;
use waterui::Binding;

#[derive(TsProps)]
struct PromoProps {
    headline: String,
    unread: Binding<u32>,
    #[ts(rename = "onDismiss")]
    on_dismiss: Box<dyn Fn()>,
}
rust
let view = tsx!(
    "./promo.tsx",
    PromoProps {
        headline: String::from("Welcome back"),
        unread,
        on_dismiss: Box::new(|| {}),
    }
);
  • Attribute order is semantic. <Text padding={16} background={…}> is text(…).padding_with(16.0).background(…) — the same attributes in another order are another view, and a modifier attribute may never arrive through a spread.
  • Component tags import from "waterui" and resolve against the catalog (<VStack> <HStack> <Text> <Button> <Toggle> <Slider> <List> <Tabs> …), with <Show> <For by> <Suspense> <Box> for control flow and createSignal, createMemo, createContext/useContext, useTheme, useLocale from the same module. <Text> children localize through the same TranslationCatalog as text(…).
  • Tests are ordinary #[waterui::test]s over the mounted tree, run through water test: it builds the bundle and hands its path to the test host in WATERUI_TS_BUNDLE, and water preview does the same for a #[preview]. Under bare cargo nextest run nothing sets it, so a tsx! mount fails naming the variable and water test.
  • The engine differs by platform: JavaScriptCore on iOS, macOS, tvOS and visionOS; QuickJS-NG on Android, Linux and Windows; unsupported on watchOS — the platform has no JavaScriptCore and embedding QuickJS-NG there is not supported.

Gotchas worth memorizing

SymptomCauseFix
UI never updates.snapshot() in a view bodypass the binding
State resets on every keystrokewatch rebuilding the subtreetext! / signal-taking API / Lazy::for_each
no function or associated item named 'new' on BindingBinding::new does not existBinding::i32(v) / Binding::container(v) / Binding::default()
cannot find function 'when' / derive Identifiablenot in the preludeuse waterui::widget::condition::when; / use waterui::Identifiable;
Rounded corners come out fully roundRoundedRectangle::new(r) takes a fraction of the shorter side, not pointsnew(0.1)-scale values, or Capsule
A background task dies instantlythe spawn_local handle cancels on drop.detach() it
LongPressGesture::new(Duration…) rejectedduration is a u32 in backend time unitsLongPressGesture::new(500)
.is_empty() missing on a string signaldifferent name.str_is_empty(), .str_len(), .str_contains(..)
Wrong binding arrives in a handlerpositional .state() extractionfirst .state() → first parameter of that type
Scrolling or list updates are jankywatch over a VecList::for_each / Lazy::for_each / SignalCollection
ForEach<..>: View is not satisfiedForEach is a collection, not a viewLazy::for_each(..), or hand it to a container
.title("Inbox") rejects its argumentText::title() (font size) shadows the navigation titletitle the container, or NavigationView::new(title, content)
A test's wait can never failquery .wait_for_existence(..) returns boolwrap it in assert!
use of undeclared crate 'tracing'logging is re-exporteduse waterui::log::debug; then debug!(..)
borrowed data escapes outside of functionviews are 'static&'static str / Str / impl IntoText, or impl View + use<>
type annotations needed after binding(v)binding takes impl Into<T>Binding::i32(0) etc., turbofish, or annotate

Rust rules still apply on top of these: no println! (import waterui::log::debug and use debug!, surfaced by water run --logs debug), and nothing blocking on the UI thread — use .action_async, .task(..), or waterui::task::{spawn_local, sleep} with .detach().

Bundled files

The model reads these on demand while the skill is loaded. They are exposed as readable files and are never executed.

Frequently asked questions

What does the Waterui AI skill do?

Build cross-platform native apps with the WaterUI Rust framework. Use this skill whenever writing, reviewing, or debugging WaterUI code — views, reactive state (Binding/Computed/signals), layout, styling, navigation, lists, forms, gestures, drag and drop, menus, windows, media, charts, maps, localization/i18n/RTL, animation, accessibility, tests — or when running the `water` CLI (`water create`, `water run`, `water preview`, `water build`, `water bench`). Trigger on any Rust file that imports `waterui`, any project containing a `Water.toml`, and any mention of WaterUI, `text!`, `vstack`/`hs...

Why use Waterui on TypingMind?

Because you install it once and use it with any model. Waterui is plain Markdown rather than provider-specific code, so the same skill runs on GPT-5, Claude, Gemini, Grok, or a local model — and you can switch model mid-chat without it breaking. TypingMind runs on your own API keys, so you pay providers directly instead of a per-seat subscription, and your skills and chats stay in your own storage.

How do I install Waterui in TypingMind?

Open Plugins → Skills → Install from GitHub in TypingMind and paste https://github.com/water-rs/waterui/tree/dev/.claude/skills/waterui. TypingMind reads its SKILL.md and bundles its files and installs it as a skill you can enable per chat.

Which AI models can use Waterui?

Any model you connect in TypingMind. AI skills are plain Markdown instructions rather than provider-specific code, so GPT, Claude, Gemini, Grok, and local models can all load this skill when a request matches it.

How many AI models can I use with Waterui?

As many as you like. As long as a model supports skills, you can use Waterui with it — GPT, Claude, Gemini, Grok, DeepSeek, Mistral, Llama and more — all on TypingMind with your own API keys.

Is the Waterui AI skill free?

Yes. It is published on GitHub by water-rs under the Apache-2.0 license. You only pay your own AI provider for the tokens you use.

What are AI skills?

An AI skill is a reusable instruction bundle that teaches an AI model how to do one specific task. It follows the open Agent Skills format: a SKILL.md file with a name and description, plus any scripts, templates or reference files the model may need. The model reads the instructions only when your request matches the skill, so an installed skill costs nothing until it is used.

How are AI skills different from plugins or MCP servers?

A plugin or MCP server gives a model new tools to call — code that runs somewhere and returns a result. An AI skill gives the model knowledge and process instead: how to approach a task, which steps to follow, what good output looks like. Skills are plain Markdown, so they need no server, no API key and no runtime, and they work with any model.

View all

Set up your own AI workspace now

Get notified about new features and future giveaways by subscribing to our newsletter 👇