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

WaterUI App Development

Build views with reactive state. When unsure, search examples/*/src/lib.rs for existing patterns.

CRITICAL: Runtime And Testing Semantics

  • WaterUI is fine-grained reactive with reconstruction semantics. If parent-driven control flow rebuilds a component instance, that instance's local state resetting is expected and correct.
  • Do not fix rebuild-driven resets by caching hidden state across rebuilds. If state must survive, lift it into explicit reactive ownership at the right level.
  • Prefer Binding, Computed, and other signal inputs for dynamic behavior. Avoid reading .get() in view bodies when a reactive API can accept the signal directly.
  • Hydrolysis widget chrome is provided by a backend-neutral WidgetTheme. For Material Design 3 output, install hydrolysis_m3::install(&mut env) before running or previewing a Hydrolysis app.
  • Hydrolysis Material 3 colors use Material You system roles for WaterUI theme tokens. Use hydrolysis_m3::MaterialColorSource::new(source_color) to configure source color, variant, contrast level, spec version, and platform; call .schemes() to generate paired light/dark MaterialColorSchemes, then install one side with install_with_source, install_with_color_schemes, or install_with_colors.
  • For Material-specific view colors, use hydrolysis_m3::color::* role tokens such as Primary, OnPrimary, PrimaryContainer, SurfaceContainerHighest, and OnSurfaceVariant. These resolve from the installed MaterialColorScheme, while WaterUI's portable theme_color::* tokens map semantic roles such as Accent, AccentContainer, Tertiary, and TertiaryContainer to the active platform theme.
  • Use water preview --backend hydrolysis --theme material3 for Hydrolysis Material 3 visual checks. Pass --expr when previewing an inline Rust expression; the CLI writes the expression into generated Rust preview code and rustc compiles it normally with waterui::prelude::* and waterui in scope.

CRITICAL: Reactive-First Pattern

WaterUI is a reactive framework. ALWAYS pass Bindings directly to APIs instead of using .get() or watch.

Most WaterUI APIs accept impl Signal or impl IntoSignalF32 - pass bindings directly for automatic reactivity:

// ✅ CORRECT - Pass binding directly, updates automatically
Photo::new(url).blur(blur_value.clone())       // blur updates as slider moves
view.visible(is_visible.clone())               // visibility reacts to state
view.opacity(opacity_value.clone())            // opacity animates reactively
view.disabled(is_loading.clone())              // disabled state follows loading
text!("Count: {count}")                        // text updates automatically

// ❌ WRONG - Static value, requires manual refresh
Photo::new(url).blur(blur_value.get())         // blur frozen at initial value
view.visible(is_visible.get())                 // visibility never changes
watch(count.clone(), |c| text(format!("{c}"))) // unnecessary indirection

Rule: If an API accepts a value that might change, check if it accepts impl Signal and pass the binding.

Quick Start

use waterui::prelude::*;

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

    vstack((
        text!("Count: {count}").headline(),
        button("+1")
            .action(|State(count): State<Binding<i32>>| count.set(count.get() + 1))
            .state(&count),
    ))
}

Views

Functions and closures are views:

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

// Use directly - no wrapper needed
vstack((card("Hello"), card("World")))

Conditional rendering:

// Show or hide (Option<impl View> is a View)
is_new.map(|b| b.then(|| badge("New")))

// Binary choice (if-else)
when(is_logged_in, || dashboard()).otherwise(|| login_form())

// Multi-branch (if-elif-else)
when(state.equal_to(0), || "Loading")
    .or(state.equal_to(1), || "Ready")
    .otherwise(|| "Error")

State

// Use type-specific constructors (Binding::new does NOT exist)
let toggle = Binding::bool(false);
let count = Binding::i32(0);
let value = Binding::f64(1.5);
let name = Binding::container(String::new());  // heap types (String, Vec, etc.)
let text = Binding::container(Str::from("hello")); // Str type

// Pass by reference to child views
fn section(count: &Binding<i32>) -> impl View { ... }

Reactive Transforms

Methods on signals (no .clone() needed for transforms):

count.not()                    // bool negation
count.select(a, b)             // if-else
count.equal_to(5)              // equality check
count.gt(0)                    // comparisons: lt, le, ge
count.is_empty()               // for strings/collections
count.map(|v| v * 2)           // custom transform
count.zip(&other).map(|(a,b)| a + b)  // combine signals

Convert to Computed: signal.computed()

Reactive Modifiers

Pass bindings directly to modifiers for real-time updates:

let opacity = Binding::f64(1.0);
let blur = Binding::f64(0.0);
let is_visible = Binding::bool(true);
let is_disabled = Binding::bool(false);
let scale_factor = Binding::f64(1.0);

view
    .opacity(opacity.clone())           // reactive opacity
    .visible(is_visible.clone())        // reactive visibility
    .disabled(is_disabled.clone())      // reactive disabled state
    .scale(scale_factor.clone(), scale_factor.clone())  // reactive scale

// Filters also accept reactive values
Photo::new(url)
    .blur(blur.clone())                 // blur updates in real-time
    .saturation(saturation.clone())     // saturation updates in real-time
    .brightness(brightness.clone())     // brightness updates in real-time

Event Handlers

IMPORTANT: Inject handler state with .state() after .action() or .action_async(); never clone bindings just to capture them. State<T> extracts the injected value from the action environment.

// Single state
button("Click")
    .action(|State(count): State<Binding<i32>>| count.set(count.get() + 1))
    .state(&count)

// Multiple states are separate typed extractor parameters
button("Reset")
    .action(
        |State(x): State<Binding<i32>>, State(y): State<Binding<i32>>| {
            x.set(0);
            y.set(0);
        },
    )
    .state(&x)
    .state(&y)

// Four states example
button("Submit")
    .action(
        |State(url): State<Binding<Str>>,
         State(blur): State<Binding<f64>>,
         State(status): State<Binding<String>>,
         State(handler): State<DynamicHandler>| {
            // Use all four injected values
        },
    )
    .state(&url)
    .state(&blur)
    .state(&status)
    .state(&handler)

// Async
button("Load").action_async(|_| async { fetch().await })

// Lifecycle
view.on_appear(|| setup())
view.on_change(&signal, |new_val| handle(new_val))

Text

IMPORTANT: Use text() for static text and text! for reactive text. Never use watch() just to build text. Also never write waterui::text!; import the macro and use text! directly.

// Static text - use text() function
text("Hello").title()       // semantic sizes: title, headline, body, caption, footnote, sub_headline

// Reactive text - use text! macro (auto-updates when bindings change)
text!("Count: {count}")              // single binding
text!("{a} + {b} = {sum}")           // multiple bindings
text!("Value: {value:.2}")           // with formatting
text!("{FOCUSED_READOUT}")           // const &str capture is fine if text! behavior is desired

// text! returns LocalizedText with font methods
text!("Status: {status}").sub_headline()
text!("Small: {value}").caption()

Layout

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

// From iterator - use .collect() for dynamic layouts
let buttons: HStack<_> = items.iter().map(|i| button(i.label)).collect();

Programmatic scrolling is explicit and repeatable through ScrollController. List targets item indices, while ScrollView targets content coordinates:

let list_scroll = ScrollController::<usize>::new(0);
let list = List::for_each(rows, row_view).scroll_controller(&list_scroll);
list_scroll.scroll_to(50_000);

let content_scroll = ScrollController::<Point>::new(Point::zero());
let viewport = scroll(lazy_content).scroll_controller(&content_scroll);
content_scroll.scroll_to(Point::new(0.0, 2_400.0));

List::for_each and ScrollView containing a lazy stack materialize only the visible window, including after a deep programmatic jump. Use List::content when section markers or heterogeneous static rows are required.

Colors

// Built-in (zero-sized, efficient)
Blue, Green, Red, Orange, Purple, Cyan, Yellow, Pink, Grey

// Custom
const BRAND: Srgb = Srgb::from_hex("#3B82F6");

// Usage - colors are Views
view.background(Blue)
view.foreground(BRAND)
Blue.size(80.0, 80.0)       // colored rectangle
BRAND.with_opacity(0.5)

Theme colors: Foreground, MutedForeground, Accent, AccentContainer, AccentForeground, Tertiary, TertiaryContainer, Background, Surface, SurfaceVariant, Border

Icons

Icons come from packaged icon-set crates — pick one set per app and depend on it: waterui-icons-material-icon (Material Symbols), waterui-icons-lucide, waterui-icons-fontawesome7, waterui-icons-sf-symbol (Apple only).

use waterui_icons_material_icon as mdi;

mdi::check_circle()                   // an icon view
mdi::delete().size(20.0, 20.0)        // size it
mdi::flag().foreground(Accent)        // theme color
mdi::calendar_today().tint(Srgb::from_hex("#4A84F6"))  // explicit tint

Match the icon set to the design language: a Material 3 (hydrolysis_m3) app uses Material icons, not Lucide. SystemIcon/SF Symbols are Apple-only — for portable code d


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)13moReviewIntermediate
tauri761moReviewAdvanced
rust-errors51moNo flagsAdvanced
hula-skill37moReviewIntermediate

Try saying

Example prompts that trigger this skill in your AI assistant.

Search skills

Search the agent skills registry