Builds reactive Rust UIs with support for WaterUI and Hydrolysis widgets.

Install

mkdir -p .claude/skills/waterui && curl -L -o skill.zip "https://agentskills.codes/api/skills/download/3603" && unzip -o skill.zip -d .claude/skills/waterui && rm skill.zip

Installs to .claude/skills/waterui

Activation

This is the description your AI agent reads to decide when to run this skill — the better it matches your request, the more reliably it fires.

Build cross-platform apps with WaterUI. Use when writing views, handling state, styling UI, or debugging WaterUI Rust code. Covers reactive bindings, layout, components, and the water CLI.
188 chars✓ has a “when” trigger
Intermediate

Key capabilities

  • →Configure Material 3 color schemes
  • →Register reactive bindings for UI components
  • →Preview layouts via CLI integration
  • →Generate theme token mappings

How it works

Injects specific Rust procedural macros and color scheme configurations into the project based on the requested design system tokens.

Inputs & outputs

You give it
UI component requirements or design tokens
You get back
Rust code with reactive signals and theme injection

When to use waterui

  • →Write reactive views in Rust
  • →Configure Material Design theme tokens
  • →Debug fine-grained reactive component state

About this skill

Building apps with WaterUI

WaterUI is a Rust UI framework that renders to real native widgets (UIKit/AppKit, Android View, GTK4) or to its own GPU renderer, 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.

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:

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.

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.

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:

#[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.

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

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:

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 `w


Content truncated.

When not to use it

  • →Non-Rust UI projects
  • →Web-only development lacking WaterUI primitives

Prerequisites

Rust toolchainWaterUI CLIHydrolysis library

Limitations

  • →UI state resets during component rebuilds are expected behavior
  • →Requires Hydrolysis-compatible backend

How it compares

It manages complex reactive state and theme injection tokens automatically, avoiding manual boilerplate for fine-grained reactivity.

Compared to similar skills

waterui side by side with the closest alternatives in the catalog.

SkillInstallsUpdatedSafetyDifficulty
waterui (this skill)14moReviewIntermediate
tauri762moReviewAdvanced
rust-errors52moNo flagsAdvanced
hula-skill38moReviewIntermediate

Try saying

Example prompts that trigger this skill in your AI assistant.

Search skills

Search the agent skills registry