Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Introduction

What is vgui?

vgui is a declarative, reactive GUI framework for Rust, built on top of gpui — the GPU-accelerated UI toolkit behind the Zed editor. It brings a familiar web-style authoring experience to native desktop applications:

  • JSX-like views via the view! macro — elements, attributes, children, fragments, and component invocation, all in ergonomic markup.
  • CSS-in-Rust via the css! macro — write real CSS declarations (color: #fff; padding: 8px;) that compile down to gpui style refinements.
  • Tailwind-style classes via the tw! macro — class="p-2 rounded hover:bg-[#000088]" works out of the box, with hover:, focus:, and active: variants.
  • Fine-grained reactivity inspired by SolidJS: create_signal, create_memo, and create_effect track dependencies automatically and re-render only what changes.
  • Control-flow components <Show> and <For> for conditional and list rendering with optional fallbacks.
  • Built-in input widgets — text fields with full cursor/selection/clipboard/ IME support, checkboxes, radio buttons, range sliders, file pickers, select dropdowns, and text areas.
  • ARIA semantic attributesrole and aria:name attributes on all elements for accessibility roles, labels, and states.
  • Context & Provider — SolidJS-style dependency injection via Context<T> and <Provider> for passing values through the element tree without prop drilling.
  • NodeRef — imperative handles for focus, scroll, and bounds queries on rendered elements.
  • Focus management — focus trap, focus restore, and roving tabindex for accessible keyboard navigation (e.g., radio groups with arrow-key nav).
  • Overlaysportal, dialog, and floating for modal dialogs and floating elements rendered on a separate layer.
  • Component variants — the variants! macro declares a base style plus dimensions (e.g., color, size) that compose into typed, Copy variant structs.
  • Animations & transitionstw! animate-* and transition-* utilities with easing functions and keyframe support.
  • Responsive breakpointssm:, md:, lg:, xl: prefixes apply styles only when the viewport width meets the threshold.
  • Dynamic class composition — the twc! macro composes conditional Tailwind classes at runtime for state-driven styling.
  • CSS variables & themingtheme! macro builds a Theme of CSS custom properties; var(--name) in css! resolves them at runtime; set_theme() enables reactive light/dark switching.
  • SPA router — signal-driven router with :param pattern matching and wildcard routes for single-page applications.
  • Dual-target — every example compiles and runs natively (Linux) and on the web (WASM) with a single codebase.

Under the hood, vgui maps every HTML element to a gpui flexbox div (or a specialized widget for inputs), compiles CSS/Tailwind declarations into gpui::StyleRefinement mutations at build time, and drives reactivity through a per-render scope-slot model that keeps stateful widgets alive across re-renders.

Features

Declarative markup

The view! macro lets you write UI trees that look almost identical to JSX:

#![allow(unused)]
fn main() {
view! {
    <div class="flex flex-col gap-3 p-4 justify-center items-center">
        <span>{"Hello, world!"}</span>
        <button on:click={click(move |_cx| {})}>{"Click me"}</button>
    </div>
}
}

Lowercase tags (<div>, <span>, <button>, <input>, …) map to built-in HTML elements. Uppercase tags (<Greeting>, <Card>) invoke your own component functions or struct constructors.

Compile-time styling

Both css! and tw! are proc-macros — there is no runtime CSS parser. Every declaration is validated and lowered to typed gpui style mutations at compile time, so typos and unsupported properties produce build errors, not silent rendering bugs.

#![allow(unused)]
fn main() {
// CSS-in-Rust
<div style={css! {
    display: flex;
    flex-direction: column;
    gap: 12px;
    background: rgb(30, 30, 30);
    color: #fff;
}}>

// Tailwind classes
<div class="flex flex-col gap-3 p-4 bg-[#505050] text-white rounded-lg">
}

Fine-grained reactivity

State is managed through signals — lightweight, observable cells that automatically track which scopes read them:

#![allow(unused)]
fn main() {
let (count, set_count) = create_signal(0i32);
let doubled = create_memo({ let count = count.clone(); move || count.get() * 2 });

view! {
    <span>{format!("count = {}", count.get())}</span>
    <span>{format!("doubled = {}", doubled.get())}</span>
    <button on:click={click(move |cx| set_count.update(cx, |n| *n += 1))}>
        {"Increment"}
    </button>
}
}

When set_count fires, only the scopes that read count (the two <span>s and the doubled memo) are re-evaluated — not the entire tree.

Control flow

<Show> and <For> are first-class constructs in view!:

#![allow(unused)]
fn main() {
<Show when={count.get() > 0} fallback={view! { <span>{"zero or negative"}</span> }}>
    <span>{"positive"}</span>
</Show>

<For each={todos.get()} fallback={view! { <div>{"No todos."}</div> }}>
    {move |todo: Todo, _i: usize| todo_item(todo)}
</For>
}

Rich input widgets

<input> supports 21 type variants — text, password, search, email, url, tel, number, date, datetime-local, time, month, week, color, checkbox, radio, range, file, submit, button, reset, and hidden — each with appropriate event handler signatures. Text-based inputs have full cursor movement, selection, clipboard (Ctrl+A/C/V/X), and IME (CJK composition) support.

Status

vgui is early-stage, experimental software. The API is not yet stable and breaking changes should be expected between releases. It is, however, fun to build with — see the Examples section for end-to-end applications.

License

Licensed under the MIT License.

Installation

Prerequisites

  • A recent stable Rust toolchain (edition 2021, ≥ 1.74 recommended).
  • pkg-config.
  • A C/C++ build toolchain: build-essential, cmake.
  • libclang for bindgen (used by gpui and its transitive deps).

System Libraries

vgui does not depend on system libraries directly, but gpui does — it talks to the native window system (Wayland and X11 on Linux, Cocoa/Metal on macOS, Win32/DirectX on Windows). The following development packages are required to build gpui on a Debian/Ubuntu Linux host:

sudo apt-get install -y \
  build-essential \
  cmake \
  pkg-config \
  libclang-dev \
  libssl-dev \
  libzstd-dev \
  libfontconfig1-dev \
  libfreetype6-dev \
  libglib2.0-dev \
  libgtk-3-dev \
  libasound2-dev \
  libdbus-1-dev \
  libxkbcommon-dev \
  libxkbcommon-x11-dev \
  libx11-dev \
  libxext-dev \
  libxrandr-dev \
  libxinerama-dev \
  libxcursor-dev \
  libxi-dev \
  libwayland-dev \
  libgl-dev \
  libegl-dev

Other distributions

Fedora / RHEL
sudo dnf install -y \
  clang-devel openssl-devel libzstd-devel fontconfig-devel freetype-devel \
  glib2-devel gtk3-devel alsa-lib-devel dbus-devel \
  libxkbcommon-devel libxkbcommon-x11-devel \
  libX11-devel libXext-devel libXrandr-devel libXinerama-devel \
  libXcursor-devel libXi-devel wayland-devel \
  mesa-libGL-devel mesa-libEGL-devel \
  cmake pkg-config
Arch Linux
sudo pacman -S --needed \
  base-devel clang cmake pkgconf \
  openssl zstd fontconfig freetype2 glib2 gtk3 alsa-lib dbus \
  libxkbcommon libxkbcommon-x11 \
  libx11 libxext libxrandr libxinerama libxcursor libxi wayland \
  mesa
macOS

No extra system libraries are required beyond Xcode Command Line Tools:

xcode-select --install

gpui uses Metal/Cocoa natively on macOS.

Windows

Build with the MSVC toolchain (rustup default stable-x86_64-pc-windows-msvc) and the Visual Studio C++ Build Tools. The Windows SDK provides the rest.

Adding vgui to Your Project

Add vgui (and gpui) to your Cargo.toml. Both are used as git dependencies — neither crate is published to crates.io:

[dependencies]
vgui = { git = "https://github.com/vgerbot-libraries/vgui" }
gpui = { git = "https://github.com/zed-industries/zed" }
gpui-platform = { git = "https://github.com/zed-industries/zed", package = "gpui_platform" }

vgui can also be used as a path dependency if you have a local checkout.

Then bring the prelude into scope:

#![allow(unused)]
fn main() {
use vgui::prelude::*;
}

The prelude exports view!, css!, tw!, twc!, variants!, theme!, the reactive primitives (create_signal, create_memo, create_effect, create_router, ReadSignal, WriteSignal, Router, RouteMatch), the click helper, mount, context API (Context, use_context, use_context_or, provide_context), NodeRef, input widget constructors and props types, styling types (TwClass, TwClassSource, IntoTwStyle, Theme), overlay helpers (portal, floating), and Breakpoint. It also re-exports gpui::prelude::* for convenience.

Verify the build compiles:

cargo build

The first build compiles gpui and its graphics backends, so expect a longer initial compile. Subsequent incremental builds are fast.

Quick Start

A Minimal App

This is the smallest useful vgui application — a window with a counter and an increment button:

use gpui::{px, size, App, Application, Bounds, WindowBounds, WindowOptions};
use vgui::prelude::*;

fn app() -> impl gpui::IntoElement {
    let (count, set_count) = create_signal(0i32);
    view! {
        <div class="flex flex-col gap-3 p-4 w-[500px] h-[500px] justify-center items-center text-white">
            <span>{format!("count = {}", count.get())}</span>
            <button
                class="p-2 bg-[#0000ff] hover:bg-[#000088] text-white rounded"
                on:click={click(move |cx| set_count.update(cx, |n| *n += 1))}
            >
                {"Increment"}
            </button>
        </div>
    }
}

fn main() {
    Application::new().run(|cx: &mut App| {
        let bounds = Bounds::centered(None, size(px(500.), px(500.0)), cx);
        cx.open_window(
            WindowOptions {
                window_bounds: Some(WindowBounds::Windowed(bounds)),
                ..Default::default()
            },
            |window, cx| vgui::mount(window, cx, app),
        )
        .unwrap();
    });
}

How it works

  1. Application::new().run(...) — starts the gpui event loop.
  2. cx.open_window(...) — opens a window with the given bounds.
  3. vgui::mount(window, cx, app) — creates a VguiRoot entity that owns the reactive scope and calls app() on every render.
  4. create_signal(0i32) — creates a reactive signal holding the count. Returns a (ReadSignal, WriteSignal) pair.
  5. view! { ... } — expands the JSX-like markup into gpui element builders at compile time.
  6. count.get() — reads the signal value and registers the current scope as a dependency, so this <span> re-renders when the count changes.
  7. click(move |cx| set_count.update(cx, |n| *n += 1)) — the click helper wraps a closure into the event handler signature gpui expects. set_count.update mutates the signal and notifies dependents.

Running the Examples

The repository includes eleven end-to-end examples under examples/:

# Minimal counter with signals, memo, <Show>, twc! class composition
cargo run -p vgui-counter

# Todo list with <For>, filtering, css! styling
cargo run -p vgui-todolist

# Styling showcase: css! macro, Tailwind classes, pseudo-states, twc!, breakpoints
cargo run -p vgui-styling

# CSS variables, theme! macro, light/dark switching
cargo run -p vgui-theming

# Component variants! macro with base + dimension styles
cargo run -p vgui-variants

# All <input> types plus <select> with groups/multiple/custom rendering
cargo run -p vgui-inputs

# HTML tag coverage: headings, lists, tables, progress, details, dialog, etc.
cargo run -p vgui-elements

# Form handling: submission, reset, field grouping, enter-to-submit
cargo run -p vgui-forms

# Context API, <Provider>, use_context
cargo run -p vgui-context

# NodeRef imperative handles (focus, scroll, bounds)
cargo run -p vgui-refs

# Focus trap, focus restore, roving tabindex
cargo run -p vgui-focus

# Overlays: portal(), dialog(), floating()
cargo run -p vgui-overlays

# Animations, transitions, keyframes
cargo run -p vgui-animation

# Canvas drawing: Context2D API, shapes, paths, text, transforms
cargo run -p vgui-canvas

# SPA router with param matching, navigation, wildcard routes
cargo run -p vgui-router

# Capstone: router + theming + context + forms + overlays
cargo run -p vgui-dashboard

Web (WASM)

All examples also build for wasm32-unknown-unknown. See the writing-examples rule for the dual-target pattern and scripts/build_wasm.sh for building WASM assets.

Project Layout

A typical vgui application has this structure:

my-app/
├── Cargo.toml
└── src/
    └── main.rs

Cargo.toml:

[package]
name = "my-app"
version = "0.1.0"
edition = "2021"

[dependencies]
vgui = { git = "https://github.com/vgerbot-libraries/vgui" }
gpui = { git = "https://github.com/zed-industries/zed" }
gpui-platform = { git = "https://github.com/zed-industries/zed", package = "gpui_platform" }

`src/main.rs` follows the pattern above: define an `app()` function that
returns `impl IntoElement`, then wire it into `Application::run` with
`vgui::mount`. As your app grows, extract component functions that take props
and return `impl IntoElement`, and compose them inside `view!` using uppercase
tags (see [Custom Components](../custom-components.md)).

Architecture

Workspace Layout

vgui is organized as a Cargo workspace with five crates:

CrateKindDescription
vguilibThe main crate: reactivity, root mounting, styling traits, widgets.
vgui-viewproc-macroThe view! macro — JSX-like syntax parser and code generator.
vgui-cssproc-macroThe css! macro — CSS declaration parser.
vgui-tailwindproc-macroThe tw! macro and the Tailwind class registry.
vgui-tailwind-corelibShared class-parse/tables for tw! and tw_dynamic (no gpui dep).

In addition, sixteen example binaries live under examples/:

ExamplePackage nameDemonstrates
Countervgui-counterSignals, create_memo, <Show>, twc! class composition.
Todo Listvgui-todolist<For> with fallback, css! styling, filtering, CRUD.
Styling Showcasevgui-stylingcss! macro, Tailwind classes, pseudo-states, twc!, responsive breakpoints.
Themingvgui-themingCSS variables, theme! macro, light/dark switching.
Component Variantsvgui-variantsvariants! macro, typed variant structs, ApplyStyle.
Inputsvgui-inputsAll <input> types, <select> with groups/multiple/custom rendering.
HTML Elementsvgui-elementsHTML tag coverage, tables, progress, details, dialog.
Formsvgui-forms<form> submission, reset, field grouping, enter-to-submit.
Context & Providervgui-contextContext API, <Provider>, use_context, multi-module.
Refs & NodeRefvgui-refsNodeRef imperative handles (focus, scroll, bounds).
Focus Managementvgui-focusFocus trap, restore, roving tabindex, on:resize.
Overlaysvgui-overlaysportal(), dialog(), floating() overlay patterns.
Animationvgui-animationAnimations, transitions, keyframes, custom animate={...}.
Canvasvgui-canvas<canvas>, Context2D API, shapes, paths, text, transforms.
Routervgui-routerSPA router with param matching, navigation, wildcard routes.
Dashboardvgui-dashboardCapstone: router + theming + context + forms + overlays.

Crate Dependencies

The dependency graph is straightforward:

vgui-view ──┐
vgui-css  ──┼──► vgui ──► gpui
vgui-tailwind ─► vgui-tailwind-core
  • vgui depends on gpui and re-exports the proc-macro crates (view, css, tw) via pub use.
  • vgui-tailwind depends on vgui-tailwind-core for shared parse logic and class tables.
  • The proc-macro crates are completely independent of vgui at the macro level — they emit ::vgui::* and ::gpui::* qualified paths, so they work in any crate that depends on vgui.
  • Application crates depend on both vgui and gpui.

The vgui crate’s internal module structure:

ModuleResponsibility
reactivecreate_signal, create_store, create_memo, create_effect, on_cleanup, ReadSignal, WriteSignal, Store, SetStore, scope management, dependency tracking, index_list/index_list_or, <Switch>/<Index> scope helpers.
rootVguiRoot entity, Scope (the reactive owner), mount().
controlshow, show_when, for_each, for_each_or, progress, meter, details.
overlayportal, floating, dialog (modal overlay with portal, click-outside, escape).
input_textTextInput widget (text fields, text areas), TextInputProps, TextAreaProps, TextKind.
input_widgetscheckbox, radio, range_input, file_input, select and their props.
labelLabel-to-input focus association via for= / wrapping.
styleCss, TwStyle, ApplyStyle trait.
childIntoViewChild trait, into_child, click helper.
routerSPA router: create_router, Router, match_pattern, build_path, RouteMatch.
contextContext API: Context<T>, use_context, use_context_or, provide_context, ProviderGuard.
ref_handleNodeRef imperative handle (focus, scroll, bounds).
ariaARIA role and attribute resolution (__resolve_aria_role).
animationAnimation and transition types (TwAnimation, TwTransition, Easing).
themeCSS variable theming: Theme, CssValue, set_theme, with_theme.
spreadSpread<E> trait for spread attributes on built-in elements.
breakpointResponsive breakpoint detection (Breakpoint, __apply_breakpoint_styles).
grid_areasgrid-template-areas and grid-area CSS property support.
formForm context: on:submit/on:reset, child submit/reset button dispatch.
webWASM helpers: intercept_keyboard_events().
tw_dynamicRuntime Tailwind class interpreter (tw_dynamic).
preludeRe-exports the common API surface + gpui::prelude::*.

How Rendering Works

The render cycle

  1. vgui::mount(window, cx, app) creates a VguiRoot gpui entity. VguiRoot holds a Scope (the reactive owner) and a Box<dyn FnMut() -> AnyElement> closure wrapping the user’s app() function.

  2. On every render, VguiRoot::render:

    • Resets scope.index to 0 on the root scope and all descendant child scopes (created by <Switch>/<Index>/routes) — this is the per-render slot counter that gives create_signal/create_memo/create_effect/ on_cleanup their stable identity across re-renders (like React’s hooks rules).
    • Calls enter_scope to set the current scope and gpui::Context in a thread-local.
    • Calls (self.render)() — the user’s app() function — which calls create_signal, view!, etc.
    • Calls exit_scope to clear the thread-local.
    • Wraps the result in a gpui::div() with a Tab/Shift+Tab key handler for focus cycling.
  3. Inside app(), calls to create_signal/create_memo/create_effect are resolved by slot index. On the first render, a new signal/memo/effect is created and stored in scope.slots[index]. On subsequent renders, the existing slot is reused — the signal’s value persists across re-renders.

  4. view! expansion produces gpui element builder expressions. Each element is a gpui::div() (or gpui::img(), gpui::svg(), or a vgui widget constructor) with .child() chains, .style() mutations, and event handlers attached.

  5. When a signal changes via WriteSignal::set or WriteSignal::update, gpui notifies the VguiRoot entity, which calls notify_dep. This traverses the root scope and all descendant child scopes recursively, re-evaluating only the memos and effects whose tracked dependencies include the changed signal, then calls cx.notify() to trigger a re-render of the VguiRoot entity — which re-runs app() from the top.

Scope disposal

Child scopes (created by <Switch> branches, <Index> items, or routes) can be disposed when they are no longer needed — e.g. when a <Switch> branch becomes inactive or an <Index> list shrinks. dispose_scope runs all on_cleanup callbacks registered in the scope (children first, depth-first), then clears all state (slots, memos, effects, subscriptions, cleanups, children). After disposal the scope is empty and can be re-entered as if freshly created.

Why re-run from the top?

Unlike SolidJS, which compiles components into fine-grained effect graphs, vgui re-runs the entire app() closure on each render. The slot model ensures that create_signal returns the same signal (not a new one) on every render, so state is preserved. The cost of re-running the closure is low because view! produces lightweight builder expressions, and gpui’s virtual-DOM-less rendering only repaints what actually changed.

Stateful widget persistence

Input widgets (TextInput, RangeInput) are gpui entities cached in reactive scope slots via get_or_create_view. On the first render, a new entity is created and stored. On subsequent renders, the same entity handle is returned, so cursor position, selection, drag state, and IME composition persist across re-renders — even though app() runs from scratch each time.

The view! Macro

Syntax Overview

The view! macro parses JSX-like syntax and expands it into gpui element builder expressions at compile time. It is a hand-rolled token-tree parser — no external parser crate — so the syntax is close to JSX but with Rust-specific extensions.

A view! invocation takes a single root node (element, fragment, or interpolation):

#![allow(unused)]
fn main() {
view! {
    <div class="p-4">
        <span>{"Hello"}</span>
    </div>
}
}

The macro expands to { let el = EXPR; el }, where EXPR is a chain of gpui builder calls.

Node types

A view! body can contain four kinds of nodes:

NodeSyntaxExpands to
Element<div>...</div>gpui::div().child(...)...
Fragment<>...</>gpui::div().child(...) (anonymous div)
Interpolation{expr}::vgui::into_child(expr)
Text"literal" or {"expr"}::vgui::into_child("literal")

Elements

Built-in HTML elements (lowercase tags)

Any lowercase tag maps to a built-in HTML element. See Built-in HTML Elements for the full list. Most elements expand to gpui::div() with appropriate default styling:

#![allow(unused)]
fn main() {
view! {
    <div>      // → gpui::div()
    <span>     // → gpui::div()
    <button>   // → gpui::div().cursor_pointer()
    <h1>       // → gpui::div().text_size(rems(2.0)).font_weight(600)
    <strong>   // → gpui::div().font_weight(FontWeight::BOLD)
    <a>        // → gpui::div().cursor_pointer().text_color(blue)
}
}

Custom components (uppercase tags)

Any tag starting with an uppercase letter is treated as a component call. See Custom Components for details:

#![allow(unused)]
fn main() {
view! {
    <Greeting name={"world"} />
    // → Greeting { name: "world" }
}
}

Control-flow components

<Show>, <For>, <Switch>, and <Index> are special-cased by the macro and expand to vgui::show / vgui::show_when / vgui::for_each / vgui::for_each_or / vgui::index_list / vgui::index_list_or function calls, plus hidden scope-management helpers for <Switch>. See Control Flow.

<Provider> is special-cased to push a context value before evaluating its children and pop it after, via vgui::__provider_scope_enter / vgui::__provider_scope_exit. See Context & Provider.

Self-closing and void elements

Both <input type="text"> and <input type="text" /> are accepted. Void elements like <br> and <hr> never have children. <input>, <textarea>, and <select> reject child nodes — they are configured entirely through attributes.

Attributes

Attributes appear inside the opening tag as name=value pairs. Values can be:

FormExampleNotes
String literalclass="flex p-4"Parsed at compile time where relevant.
Expressionon:click={expr}Any Rust expression in braces.
Boolean literaldisabled={true}true / false literals.
Integer literaltabindex={0}Numeric literal or {expr}.

Attribute categories

AttributeSyntaxApplies toEffect
stylestyle={css!{...}}All elementsApplies CSS-in-Rust styles.
classclass="..."All elementsExpands via tw! to Tailwind utilities.
hoverhover={css!{...}}All elementsPseudo-state style on hover.
activeactive={css!{...}}All elementsPseudo-state style on mouse-down.
focusfocus={css!{...}}All elementsPseudo-state style on focus.
idid="my-id"All elementsSets the gpui element id.
tabindextabindex={0}All elements≥0 sets tab order; <0 is focusable.
on:eventon:click={handler}All elementsAttaches an event handler.
typetype="text"<input> onlySelects the input widget kind.
srcsrc={path}<img>, <svg> onlyImage/SVG path.
forfor="id"<label> onlyAssociates label with input by id.
refref={node_ref}All elementsBinds a NodeRef handle for imperative ops (focus, scroll, bounds).
rolerole="button"All elementsSets ARIA role via __resolve_aria_role.
aria:namearia:label="..."All elementsSets ARIA attribute (aria:label, aria:description, aria:keyshortcuts, aria:selected, aria:expanded, aria:toggled, aria:valuenow/aria:numeric_value, aria:value, aria:placeholder, aria:numeric_value_step).

Event handlers

Events use the on:event={handler} syntax. Supported events:

EventHandler signature
on:clickFn(&ClickEvent, &mut Window, &mut App)
on:keydownFn(&KeyboardEvent, &mut Window, &mut App)
on:keyupFn(&KeyboardEvent, &mut Window, &mut App)
on:pointerdownFn(&PointerEvent, &mut Window, &mut App)
on:pointerupFn(&PointerEvent, &mut Window, &mut App)
on:pointermoveFn(&PointerEvent, &mut Window, &mut App)
on:resizeFn(&ResizeEvent, &mut Window, &mut App)
on:scrollFn(&ScrollWheelEvent, &mut Window, &mut App)
on:wheelFn(&WheelEvent, &mut Window, &mut App)
on:dblclickFn(&PointerEvent, &mut Window, &mut App)
on:contextmenuFn(&PointerEvent, &mut Window, &mut App)
on:modifiers_changedFn(&ModifiersChangedEvent, &mut Window, &mut App)
on:mouse_down_outFn(&MouseDownEvent, &mut Window, &mut App)
on:mouse_up_outFn(&MouseUpEvent, &mut Window, &mut App)
on:any_mouse_downFn(&MouseDownEvent, &mut Window, &mut App)

For <img> only, two additional events are available:

EventHandler signature
on:loadFn(&mut App)
on:errorFn(&mut App)

These fire when the image source finishes loading or fails to load. No click() wrapper is needed — pass the closure directly, like on:close on <dialog>.

For on:click, the click helper wraps a simpler closure:

#![allow(unused)]
fn main() {
on:click={click(move |cx: &mut App| { /* ... */ })}
}

For <input>, two additional events are available:

EventHandler signature (text-based)Handler signature (checkbox/radio)Handler signature (range)Handler signature (file)
on:inputFnMut(&str, &mut App)
on:changeFnMut(&str, &mut App)FnMut(bool, &mut App)FnMut(f64, &mut App)FnMut(Vec<PathBuf>, &mut App)

Spread Attributes

The {..expr} (or {...expr}) syntax spreads a props value onto an element or component — vgui’s equivalent of SolidJS’s {...props}. This enables the rest-props forwarding pattern.

On components (struct update syntax)

For custom components, spread expands to Rust’s struct update syntax:

#![allow(unused)]
fn main() {
view! { <Greeting {..props} /> }
// → Greeting { ..props }

view! { <Greeting {..props} name={"override"} /> }
// → Greeting { name: "override", ..props }
}

Explicit fields always override the spread, regardless of source order — this is Rust’s struct update rule (named fields win over ..base). Only one spread is allowed per component (Rust struct update syntax permits a single ..base).

On built-in elements (Spread trait)

For built-in HTML elements (<div>, <button>, …), spread calls the Spread<E> trait after the element is built and children are attached:

#![allow(unused)]
fn main() {
view! { <div {..extras}>{"x"}</div> }
// → let mut el = gpui::div();
//   el = el.child("x");
//   el = ::vgui::Spread::spread(extras, el);
}

Implement Spread<E> for your props type, where E is the concrete gpui element type after other attributes are applied:

  • Bare <div {..p} />E = gpui::Div
  • With class / on:click / ref / tabindex / id / active / focusE = gpui::Stateful<gpui::Div>
#![allow(unused)]
fn main() {
struct DivExtras { bg: gpui::Hsla }

impl ::vgui::Spread<gpui::Div> for DivExtras {
    fn spread(self, el: gpui::Div) -> gpui::Div {
        el.bg(self.bg)
    }
}
}

Explicit attributes are applied before spread, so they take precedence.

Limitations

  • One spread per element — both components and built-ins accept at most one {..expr}. To merge multiple props sources, construct a single merged struct in Rust.
  • Not supported on <input>, <select>, <textarea>, <label> — these specialized elements reject spread attributes with a compile error.

Children

Children appear between the opening and closing tags. Each child is one of the four node types (element, fragment, interpolation, text):

#![allow(unused)]
fn main() {
view! {
    <div>
        <span>{"First child"}</span>
        {some_function()}
        "Plain text"
        <>
            <p>{"Fragment child A"}</p>
            <p>{"Fragment child B"}</p>
        </>
    </div>
}
}

Multiple children are chained via .child() calls:

#![allow(unused)]
fn main() {
// Expands to:
let mut el = gpui::div();
el = el.child(child1);
el = el.child(child2);
el
}

An element with no children simply omits the .child() chain.

Interpolation

{expr} interpolates any Rust expression that implements gpui::IntoElement (or IntoViewChild). The expression is wrapped in ::vgui::into_child(expr):

#![allow(unused)]
fn main() {
view! {
    <div>
        {format!("count = {}", count.get())}
        {increment_button(set_count.clone())}
    </div>
}
}

Common interpolatable values:

  • String / &str — rendered as text.
  • format!(...) — rendered as text.
  • Any impl IntoElement — rendered as a child element.
  • Component function calls returning impl IntoElement.

Refs

The ref={node_ref} attribute binds a NodeRef handle to an element, enabling imperative operations like focus(), scroll_to(), and bounds(). This is vgui’s equivalent of SolidJS’s ref.

#![allow(unused)]
fn main() {
let my_ref = NodeRef::new();
view! {
    <div ref={my_ref.clone()}>
        {"content"}
    </div>
}
// Later, in an event handler:
// my_ref.focus(window, cx);
// my_ref.scroll_to_bottom();
}

See Refs & NodeRef for the full API.

Reactivity

vgui uses a SolidJS-inspired reactivity model layered on top of gpui entities. State lives in signals — lightweight observable cells that automatically track which scopes read them. Derived state uses memos, and side effects use effects.

Signals

Creating signals

create_signal(initial) returns a (ReadSignal<T>, WriteSignal<T>) pair:

#![allow(unused)]
fn main() {
let (count, set_count) = create_signal(0i32);
let (name, set_name) = create_signal("world".to_string());
}

The type parameter T must implement Clone + PartialEq + 'static. The PartialEq bound is used to skip notifications when the new value equals the old — set_count.set(cx, 5) when the count is already 5 is a no-op.

Rule: create_signal must be called inside app() (or a function called from app() during render). It panics if no VguiRoot scope is active. Calls must appear in the same order on every render — like React hooks — because they are resolved by a per-render slot index.

Reading signals

ReadSignal::get() returns a clone of the current value and registers the current reactive scope as a dependency:

#![allow(unused)]
fn main() {
let value = count.get(); // registers dependency
}

ReadSignal::get_with(cx) reads directly from the gpui entity without registering a dependency — useful when you need the latest value outside a tracking context:

#![allow(unused)]
fn main() {
let id = next_id.get_with(cx);
}

ReadSignal is Clone, so you can pass copies into closures:

#![allow(unused)]
fn main() {
let count_clone = count.clone();
let doubled = create_memo(move || count_clone.get() * 2);
}

Writing signals

WriteSignal::set(cx, value) replaces the value and notifies dependents if the value changed:

#![allow(unused)]
fn main() {
set_count.set(cx, 10);
}

WriteSignal::update(cx, f) mutates the value in place and notifies dependents if the result changed:

#![allow(unused)]
fn main() {
set_count.update(cx, |n| *n += 1);
set_todos.update(cx, |todos| todos.push(new_todo));
}

update returns the return value of the closure, which is useful for extracting information while mutating:

#![allow(unused)]
fn main() {
let old_value = set_count.update(cx, |n| {
    let old = *n;
    *n = 0;
    old
});
}

WriteSignal is Clone.

Stores

create_store(initial) creates a reactive store for aggregate state — a single struct or enum holding multiple fields, rather than one signal per field. It returns a (Store<T>, SetStore<T>) pair:

#![allow(unused)]
fn main() {
#[derive(Clone, Default)]
struct AppState {
    count: i32,
    name: String,
    items: Vec<String>,
}

let (state, set_state) = create_store(AppState::default());
}

The type parameter T must implement Clone + 'static — notably, no PartialEq is required. Unlike signals, store writes always notify; fine-grained filtering is delegated to select (below).

create_store follows the same slot-caching rules as create_signal: it must be called inside app() in the same order on every render.

Reading stores

Store::get() returns a clone of the entire state and registers the store as a dependency:

#![allow(unused)]
fn main() {
let count = state.get().count;
}

Store::with(f) borrows the state through a closure without cloning:

#![allow(unused)]
fn main() {
let len = state.with(|s| s.items.len());
}

Both get() and with() track the entire store — any write triggers a re-render. For fine-grained reactivity, use select.

Fine-grained selectors

Store::select(f) derives a ReadSignal<U> from a slice of the store. The closure acts as a lens — the Rust-idiomatic equivalent of SolidJS path-level tracking:

#![allow(unused)]
fn main() {
let count = state.select(|s| s.count);
let name = state.select(|s| s.name.clone());
}

The selector recomputes whenever the store changes, but only notifies its dependents when the selected value differs (requiring `U: Clone + PartialEq

  • ’static). Updating namedoes **not** causecount`’s dependents to re-render — even though the store itself always notifies.
#![allow(unused)]
fn main() {
// Only re-renders when `count` changes.
set_state.update(cx, |s| s.name = "Alice".to_string()); // count unchanged
set_state.update(cx, |s| s.count = 42);                 // count changes
}

Writing stores

SetStore::set(cx, value) replaces the entire state:

#![allow(unused)]
fn main() {
set_state.set(cx, AppState { count: 0, name: "reset".into(), items: vec![] });
}

SetStore::update(cx, f) mutates the state in place through a closure — the idiomatic way to do partial updates:

#![allow(unused)]
fn main() {
set_state.update(cx, |s| {
    s.count += 1;
    s.items.push("new item".to_string());
});
}

Both methods always notify. Use select downstream to filter reactivity to the slices that actually changed.

Store vs. signal

create_signalcreate_store
Best forSingle flat valueAggregate state tree
PartialEq on TRequiredNot required
Write notifiesOnly if value changedAlways
Fine-grainedN/A (single value)Via select (lens closures)
Slot-cachedYesYes

Memos

create_memo(f) creates a derived, cached value that recomputes only when its dependencies change. It returns a ReadSignal<T>:

#![allow(unused)]
fn main() {
let doubled = create_memo({
    let count = count.clone();
    move || count.get() * 2
});

let remaining = create_memo({
    let todos = todos.clone();
    move || todos.get().iter().filter(|t| !t.done).count()
});
}

On the first render, the memo’s closure runs once and its result is cached. The signals it read (count, todos) are recorded as its dependencies. On subsequent renders, the cached value is returned without re-running the closure. When any dependency changes, VguiRoot::notify_dep re-runs the memo’s closure, updates the cache if the result changed, and propagates the notification to the memo’s own dependents.

Memos are ideal for:

  • Computed values derived from one or more signals.
  • Filtered/sorted lists.
  • Expensive transformations you don’t want to repeat every render.

Effects

create_effect(f) runs a side effect immediately (during the first render) and re-runs it whenever its dependencies change:

#![allow(unused)]
fn main() {
create_effect({
    let count = count.clone();
    move || {
        eprintln!("count changed to: {}", count.get());
    }
});
}

Note: Effects run synchronously during render, not after paint. The first invocation happens at registration time. Re-entrancy from the first effect calling setters is user-visible and not deferred.

Effects are useful for:

  • Logging / debugging.
  • Persisting state to disk.
  • Synchronizing external systems.

Cleanup

on_cleanup(f) registers a callback that runs when the current scope is disposed. Disposal happens when:

  • A <Switch> branch becomes inactive (the user switches to another branch).
  • An <Index> item is removed (the list shrinks below the item’s position).
  • A route change disposes the previous route’s child scope.
#![allow(unused)]
fn main() {
let (mode, set_mode) = create_signal("edit");

// Inside a <Switch> branch:
on_cleanup(move || {
    eprintln!("edit scope disposed — saving draft");
});
}

on_cleanup uses the same slot-caching pattern as create_effect: on re-renders the slot is recognised by position and the callback is not re-registered. The callback runs once, when the scope is disposed.

Note: on_cleanup is a no-op when no reactive scope is active (e.g. in standalone tests), so it can be safely called in test view!s without panicking.

Cleanups run depth-first: children are disposed before their parent. Within a scope, cleanups run in registration order.

Dependency Tracking

Dependency tracking is automatic and fine-grained. When ReadSignal::get() is called, it pushes its gpui::EntityId onto a thread-local TRACKING list. The tracking list is active during:

  • create_memo’s initial computation.
  • create_effect’s initial run.

After the closure completes, the collected entity IDs are stored as the memo/effect’s dependency set. When a signal changes, VguiRoot::notify_dep iterates all memos and effects, and re-runs only those whose dependency set contains the changed signal’s entity ID.

#![allow(unused)]
fn main() {
let (a, set_a) = create_signal(1);
let (b, set_b) = create_signal(2);

let sum = create_memo({
    let a = a.clone();
    let b = b.clone();
    move || a.get() + b.get() // depends on both a and b
});

let only_a = create_memo({
    let a = a.clone();
    move || a.get() * 10 // depends only on a
});

// Changing b re-runs sum but not only_a
set_b.set(cx, 5);

// Changing a re-runs both sum and only_a
set_a.set(cx, 10);
}

Auto IDs

The view! macro automatically assigns stable element IDs to elements that need them — those with on:click, hover, active, focus, class, or tabindex attributes but no explicit id.

next_auto_id() returns a u64 from a per-render counter that resets to 0 on every render. This means the same logical element receives the same id across re-renders (preserving gpui stateful state like focus and interaction), while distinct elements — such as siblings produced by a <For> closure invoked multiple times — receive distinct ids.

When no reactive scope is active (e.g., in standalone tests), a fallback counter starting at u64::MAX / 2 is used to avoid collisions.

You rarely need to call next_auto_id() directly — the macro handles it automatically. You only need it if you construct stateful gpui elements outside of view!.

Router

vgui includes a minimal single-page-app (SPA) router built on the reactive system. The current path is stored in a Signal<String>, and route patterns like /users/:id are matched against it to extract parameters.

Creating a router

create_router(initial) creates a Router backed by a signal. The Router is Clone (inner data is reference-counted), so it can be freely cloned into closures.

#![allow(unused)]
fn main() {
use vgui::prelude::*;
use vgui::router::create_router;

fn app() -> impl gpui::IntoElement {
    let router = create_router("/");
    // ...
}
}

router.navigate(cx, path) updates the underlying signal, which triggers a re-render of any scope that read router.path(). This is the only way to change the current route.

#![allow(unused)]
fn main() {
router.navigate(cx, "/users/42");
}

Reading the path

MethodDescription
router.path()Reactive read — tracks as a dependency in memos/effects.
router.path_with(cx)Non-tracking read via the gpui context.
router.path_signal()Returns the underlying ReadSignal<String>.

Pattern matching

match_pattern(pattern, path) matches a route pattern against a path string and returns Option<RouteMatch>:

  • :param segments capture a single path segment into params.
  • A trailing * wildcard captures the rest of the path.
  • Trailing slashes are normalized (treated as no trailing slash).
#![allow(unused)]
fn main() {
use vgui::router::match_pattern;

let m = match_pattern("/users/:id", "/users/42").unwrap();
assert_eq!(m.params.get("id"), Some(&"42".to_string()));

let m = match_pattern("/files/*", "/files/a/b/c").unwrap();
assert_eq!(m.params.get("*"), Some(&"a/b/c".to_string()));
}

RouteMatch has three fields:

FieldTypeDescription
patternStringThe pattern that matched (e.g. /users/:id).
pathStringThe actual path that was matched (e.g. /users/42).
paramsHashMap<String, String>Extracted parameters.

Building paths

build_path(pattern, params) substitutes :param placeholders with values from the params map. Missing params are left as-is.

#![allow(unused)]
fn main() {
use vgui::router::build_path;
use std::collections::HashMap;

let mut params = HashMap::new();
params.insert("id".to_string(), "42".to_string());
assert_eq!(build_path("/users/:id", &params), "/users/42");
}

Route dispatch

router.render(cx, routes, fallback) iterates a slice of (&str, handler) pairs and renders the first matching route’s element. Each handler receives &HashMap<String, String> of extracted params. If no route matches, the fallback closure is called.

#![allow(unused)]
fn main() {
use vgui::prelude::*;
use vgui::router::create_router;
use vgui::view;

fn app() -> impl gpui::IntoElement {
    let router = create_router("/");

    view! {
        <div>
            {router.render(cx, &[
                ("/", |_| view! { <div>{"Home"}</div> }.into_any_element()),
                ("/users/:id", |params| {
                    let id = params.get("id").unwrap_or("?");
                    view! { <div>{"User "}{id}</div> }.into_any_element()
                }),
                ("/*", |_| view! { <div>{"Not found"}</div> }.into_any_element()),
            ])}
        </div>
    }
}
}

router.match_route(pattern) is a convenience that matches a single pattern against the current path and returns Option<RouteMatch>.

Signal-driven re-render

Navigation is signal-driven: calling navigate updates the path signal, which triggers a re-render of any scope that read router.path() or router.path_signal(). No manual re-render call is needed — the reactive system handles propagation automatically.

CSS-in-Rust (css!)

The css! macro parses CSS-like declarations at compile time and emits a vgui::Css value — a closure that mutates a gpui::StyleRefinement field-by-field. There is no runtime CSS parser; every property and value is validated at build time, so typos and unsupported properties produce compile errors.

Basic Usage

Pass css! to the style attribute of any element:

#![allow(unused)]
fn main() {
view! {
    <div style={css! {
        display: flex;
        flex-direction: column;
        gap: 12px;
        padding: 20px;
        background: rgb(30, 30, 30);
        color: #fff;
    }}>
        <span>{"Hello"}</span>
    </div>
}
}

Declarations are property: value; pairs, just like CSS. Semicolons separate declarations; the trailing semicolon is optional.

An empty css! {} produces a no-op style:

#![allow(unused)]
fn main() {
let empty = css! {}; // Css::new(|_| {})
}

Value Types

Lengths

SyntaxExampleMaps to
Npx8pxgpui::px(8.0)
Nrem1.5remgpui::rems(1.5)
N%50%gpui::relative(0.5)
autoautogpui::Length::Auto
bare N8gpui::px(8.0) (treated as px)

Multi-value shorthand is supported for padding, margin, inset, and gap:

#![allow(unused)]
fn main() {
css! {
    padding: 8px 16px;          /* top=8, right=16, bottom=8, left=16 */
    padding: 8px 16px 4px;      /* error — only 1, 2, or 4 values */
    padding: 8px 16px 4px 12px; /* top=8, right=16, bottom=4, left=12 */
    margin: 0 auto;             /* top/bottom=0, left/right=auto */
    gap: 12px;                  /* row-gap=12, column-gap=12 */
    gap: 8px 16px;              /* row-gap=8, column-gap=16 */
}
}

Colors

SyntaxExampleMaps to
Hex #rgb#fffgpui::rgb(0xffffff)
Hex #rrggbb#ff0000gpui::rgb(0xff0000)
Hex #rrggbbaa#0000ff80gpui::rgba(0x0000ff, 0x80)
rgb(r,g,b)rgb(30, 30, 30)gpui::rgb(0x1e1e1e)
rgba(r,g,b,a)rgba(0,0,255,0.5)gpui::rgba(0x0000ff, 0x80)
Namedredgpui::red()

Named colors: black, white, red, green, blue, yellow, cyan, magenta, orange, purple, gray / grey.

Gradients

background accepts linear-gradient(angle, from, to):

#![allow(unused)]
fn main() {
css! {
    background: linear-gradient(90deg, #ff0000, #0000ff);
    background: linear-gradient(to right, #ff0000, #0000ff);
}
}

Supported angle forms: Ndeg, to right, to left, to top, to bottom.

Keywords

Many properties accept keyword values that map to gpui enums:

#![allow(unused)]
fn main() {
css! {
    display: flex;           /* flex | block | none | grid */
    visibility: hidden;      /* hidden | visible */
    overflow: hidden;        /* hidden | scroll | visible */
    position: relative;      /* relative | absolute */
    flex-direction: column;  /* row | column | row-reverse | column-reverse */
    flex-wrap: wrap;         /* nowrap | wrap | wrap-reverse */
    justify-content: center; /* flex-start | flex-end | center | space-between | space-around | space-evenly */
    align-items: center;     /* flex-start | flex-end | center | baseline | stretch */
    text-align: center;      /* left | center | right */
    font-style: italic;      /* italic | normal */
    white-space: nowrap;     /* nowrap | normal */
    border-style: dashed;    /* solid | dashed */
    cursor: pointer;         /* pointer | default | text | crosshair | not-allowed | grab | grabbing */
}
}

Numbers

Plain numeric literals are accepted where a number is expected:

#![allow(unused)]
fn main() {
css! {
    flex-grow: 1;
    flex-shrink: 0;
    opacity: 0.5;
    line-height: 1.5;       /* unitless → relative() */
    grid-template-columns: 3;
}
}

Font weight

font-weight accepts both named and numeric forms:

#![allow(unused)]
fn main() {
css! {
    font-weight: bold;   /* thin | extra-light | light | normal | medium | semibold | bold | extrabold | black */
    font-weight: 700;    /* 100..900 */
}
}

Pseudo-States

Pseudo-state styles do not go inside css!. Instead, they are applied as separate attributes on the element itself:

#![allow(unused)]
fn main() {
view! {
    <button
        style={css! { padding: 8px 16px; background: #dc2626; border-radius: 4px; }}
        hover={css! { background: #b91c1c; }}
        active={css! { background: #991b1b; }}
        focus={css! { border: 2px solid #fbbf24; }}
        on:click={click(|_cx| {})}
    >
        {"Delete"}
    </button>
}
}

The css! macro rejects &:hover { ... } pseudo-selectors with a compile error — they belong on the element as hover/active/focus attributes.

Interpolation

Some properties accept a {expr} interpolation that splices a runtime Rust expression into the style setter. The expression must produce a type that converts into the appropriate gpui type:

#![allow(unused)]
fn main() {
let dynamic_color = gpui::rgb(0xff0000);
let dynamic_width = gpui::px(200.0);

view! {
    <div style={css! {
        background: {dynamic_color};     /* → gpui::Hsla */
        width: {dynamic_width};          /* → gpui::Length */
        opacity: {some_f32};             /* → f32 */
        flex-grow: {grow_val};           /* → f32 */
        gap: {gap_val};                  /* → gpui::DefiniteLength */
    }}>
        <span>{"Dynamic"}</span>
    </div>
}
}

Supported interpolation properties:

PropertyExpected type
width, heightimpl Into<gpui::Length>
min-width, min-heightimpl Into<gpui::Length>
max-width, max-heightimpl Into<gpui::Length>
flex-grow, flex-shrinkf32 (cast)
flex-basisimpl Into<gpui::Length>
gapimpl Into<gpui::DefiniteLength>
grid-template-columns/rowsu16 (cast)
aspect-ratiof32 (cast)
opacityf32 (cast)
background / background-colorimpl Into<gpui::Hsla>
colorimpl Into<gpui::Hsla>
font-weightgpui::FontWeight
font-sizeimpl Into<gpui::DefiniteLength>
line-heightimpl Into<gpui::DefiniteLength>
font-familyimpl Into<gpui::SharedString>

CSS Variables (Custom Properties)

css! supports CSS custom properties (--name: value) and the var() function for runtime theming. Custom properties defined inside css! provide compile-time defaults; var(--name) emits a runtime lookup against a thread-local theme store.

Defining and using variables

#![allow(unused)]
fn main() {
css! {
    --primary: #ff0000;
    color: var(--primary);
    background: var(--bg, #fff);  /* fallback if --bg is unset */
}
}

--name: value declarations emit no runtime code — they only register compile-time defaults for var() references in the same block. A var() with no local definition and no fallback panics at runtime if the theme store also lacks the variable.

Runtime themes

The theme! macro builds a vgui::Theme value, and set_theme() installs it globally (thread-local). Theme values override compile-time defaults:

#![allow(unused)]
fn main() {
use vgui::{set_theme, theme};

set_theme(theme! {
    --primary: #0000ff;
    --bg: #1a1a1a;
});

// Now var(--primary) resolves to blue, overriding the css! default.
}

Theme::set_* builders are available for runtime-constructed themes:

#![allow(unused)]
fn main() {
use vgui::{CssValue, Theme};

let mut t = Theme::new();
t.set_color("primary", gpui::rgb(0x0000ff));
t.set_length("spacing", gpui::px(16.));
set_theme(t);
}

Supported value types in var()

All value types work: color, length (px/rem/%/auto), number, and keyword. The property determines which type is expected:

#![allow(unused)]
fn main() {
css! {
    --dir: column;
    --gap: 12px;
    --o: 0.5;
    flex-direction: var(--dir);   /* keyword */
    gap: var(--gap);              /* length */
    opacity: var(--o);            /* number */
}
}

Gradients with var()

linear-gradient color arguments can be var() references:

#![allow(unused)]
fn main() {
css! {
    --a: #ff0000;
    --b: #0000ff;
    background: linear-gradient(90deg, var(--a), var(--b));
}
}

Shorthand restrictions

Multi-value shorthands (padding, margin, inset, gap) accept var() only as the sole value — padding: var(--p) works, but padding: 8px var(--p) is a compile error. Use longhand properties (padding-top, etc.) to mix literal and variable values. The border shorthand does not support var(); use border-width, border-color, or border-style with var() instead.

Reactivity

Theme changes are not auto-reactive — set_theme does not notify gpui. To re-render on a theme swap, read a signal inside the render closure and call set_theme there:

#![allow(unused)]
fn main() {
fn app(mode: ReadSignal<bool>) -> impl IntoView {
    set_theme(theme_for_mode(mode.get()));
    // ... rest of render ...
}
}

Reading mode.get() registers the reactive dependency, so toggling mode re-runs render, re-sets the theme, and rebuilds styled elements.

tw! scope

var() is not supported inside tw! (Tailwind has its own theme system). Use style={css!{...}} for variable-driven styling.

Tailwind Classes (tw!)

The tw! macro compiles Tailwind-style utility class strings into gpui::StyleRefinement mutations at build time. It is invoked automatically when you use the class="..." attribute on any element in view!, or you can call it directly.

Basic Usage

In view!, the class attribute is expanded through tw!:

#![allow(unused)]
fn main() {
view! {
    <div class="flex flex-col gap-3 p-4 bg-[#505050] w-[500px] h-[500px] justify-center items-center text-white">
        <button class="p-2 bg-[#0000ff] hover:bg-[#000088] rounded">{"Click"}</button>
    </div>
}
}

You can also call tw! directly, though this is rarely needed:

#![allow(unused)]
fn main() {
let style = tw!("flex p-4 bg-white");
}

The macro produces a vgui::TwStyle struct with four closures: base, hover, focus, and active. The view! macro wires these to the appropriate gpui pseudo-state handlers automatically.

Unknown classes are silently skipped — the macro does not error on unrecognized utilities.

Variants

Three variant prefixes are supported, each applying styles only in the corresponding interaction state:

PrefixStateExample
hover:Mouse hoverhover:bg-[#000088]
focus:Keyboard focusfocus:border-blue-500
active:Mouse downactive:bg-[#000066]
#![allow(unused)]
fn main() {
view! {
    <button class="bg-blue-600 hover:bg-blue-700 active:bg-blue-800 focus:ring-2 text-white px-4 py-2 rounded">
        {"Save"}
    </button>
}
}

Variants can be stacked with any utility class: hover:text-white, focus:outline-none, active:scale-95 (if supported).

Responsive Breakpoints

Four responsive prefixes apply styles only when the viewport width meets the threshold:

PrefixMin widthExample
sm:≥ 640pxsm:flex-row
md:≥ 768pxmd:flex-row
lg:≥ 1024pxlg:text-lg
xl:≥ 1280pxxl:grid-cols-4
#![allow(unused)]
fn main() {
view! {
    <div class="flex-col md:flex-row">
        {"Stacks on small screens, rows on medium and up."}
    </div>
}
}

Breakpoint closures are applied via __apply_breakpoint_styles reading the viewport width set during render. Each prefix generates a closure that checks the current width and applies its styles only when the threshold is met.

Dynamic Class Composition

The twc! macro composes conditional Tailwind classes at runtime. It takes a base string plus zero or more Option<&str> arguments, including only the ones that are Some:

#![allow(unused)]
fn main() {
view! {
    <button class={twc!(
        "p-2 rounded text-white",
        (delta > 0).then_some("bg-blue-500 hover:bg-blue-600"),
        (delta < 0).then_some("bg-red-500 hover:bg-red-600"),
        (count.get() == 0).then_some("bg-gray-500")
    )}>
        {label}
    </button>
}
}

twc! returns a TwStyle with last-write-wins semantics per CSS field. When multiple conditional classes set the same property, the last matching one wins.

TwClass builder

For programmatic construction, TwClass provides a builder API:

#![allow(unused)]
fn main() {
let cls = TwClass::new()
    .add("p-4")
    .add_if(some_cond, "bg-red-500");
}

TwClassSource trait

TwClassSource is implemented for &str, String, Option<T>, and TwClass, allowing any of these to be used where a class source is expected.

IntoTwStyle trait

IntoTwStyle converts class sources into TwStyle. Using class={twc!(...)} or class={some_string} (a non-literal expression) routes through IntoTwStyle, enabling dynamic class composition at runtime.

tw_dynamic runtime interpreter

tw_dynamic(classes: &str) interprets a class string at runtime, producing a TwStyle. This is the runtime counterpart to the compile-time tw! macro, useful when class strings are not known at compile time.

For animate-* and transition-* classes, see Animations & Transitions.

Arbitrary Values

Arbitrary values use the [...] bracket syntax:

#![allow(unused)]
fn main() {
class="bg-[#0000ff] w-[500px] h-[300px] text-[#ff0000] rounded-[8px] p-[12px]"
}

Supported arbitrary value types:

CategorySyntaxExample
Colors[#hex]bg-[#ff0000]
Colors[rgb(r,g,b)]bg-[rgb(255,0,0)]
Colors[rgba(r,g,b,a)]bg-[rgba(0,0,255,0.5)]
Lengths[Npx]w-[500px]
Lengths[Nrem]w-[20rem]
Lengths[N%]w-[50%]
Lengths[N] (bare)w-[200] (treated as px)

Opacity modifier

Color utilities accept an /NN opacity suffix:

#![allow(unused)]
fn main() {
class="bg-blue-500/50 text-black/75"
}

The /NN value (0–100) sets the alpha channel of the color.

Supported Utilities

Display

ClassEffect
flexdisplay: flex
blockdisplay: block
hiddendisplay: none
griddisplay: grid
inline-flexdisplay: flex

Flex direction

flex-row, flex-col, flex-row-reverse, flex-col-reverse

Flex wrap

flex-wrap, flex-nowrap, flex-wrap-reverse

Flex grow / shrink

ClassEffect
flex-1grow=1, shrink=1, basis=0
flex-autogrow=1, shrink=1, basis=auto
flex-nonegrow=0, shrink=0, basis=auto
flex-growgrow=1
flex-grow-0grow=0
flex-shrinkshrink=1
flex-shrink-0shrink=0
flex-grow-Ngrow=N (arbitrary number)
flex-shrink-Nshrink=N

Justify content

justify-start, justify-end, justify-center, justify-between, justify-around, justify-evenly

Align items

items-start, items-end, items-center, items-baseline, items-stretch

Align self

self-start, self-end, self-center, self-stretch, self-baseline

Align content

content-center, content-start, content-end, content-between, content-around, content-stretch, content-evenly

Position

relative, absolute, static

Overflow

overflow-hidden, overflow-scroll, overflow-auto, overflow-visible, overflow-x-*, overflow-y-*

Visibility

visible, invisible

Spacing

The spacing scale maps class suffixes to pixel values:

SuffixpxSuffixpxSuffixpx
004161248
px15201456
0.526241664
147282080
1.568322496
2893632128
2.510104048192
312114496384

Padding utilities: p-N (all), px-N (inline), py-N (block), pt-N, pr-N, pb-N, pl-N, ps-N, pe-N.

Margin utilities: m-N, mx-N, my-N, mt-N, mr-N, mb-N, ml-N, ms-N, me-N. Also m-auto, mx-auto, my-auto, mt-auto, mr-auto, mb-auto, ml-auto.

Gap

gap-N, gap-x-N, gap-y-N

Sizing

ClassEffect
w-fullwidth: 100%
w-autowidth: auto
w-fitwidth: auto
w-screenwidth: 100%
h-fullheight: 100%
h-autoheight: auto
h-fitheight: auto
h-screenheight: 100%
min-w-fullmin-width: 100%
min-w-automin-width: auto
min-h-fullmin-height: 100%
min-h-automin-height: auto
max-w-fullmax-width: 100%
max-w-nonemax-width: auto
max-h-fullmax-height: 100%
max-h-nonemax-height: auto

Arbitrary: w-[500px], h-[300px], min-w-[200px], max-h-[400px].

Colors

22 named color palettes, each with 11 shades (50–950):

slate, gray, zinc, neutral, stone, red, orange, amber, yellow, lime, green, emerald, teal, cyan, sky, blue, indigo, violet, purple, fuchsia, pink, rose

#![allow(unused)]
fn main() {
class="bg-blue-500 hover:bg-blue-600 text-gray-100 border-gray-300"
}

Special colors: bg-black, bg-white, bg-transparent, text-black, text-white, text-transparent, border-black, border-white, border-transparent.

Typography

Font weight: font-thin, font-light, font-normal, font-medium, font-semibold, font-bold, font-extrabold, font-black.

Font size: text-xs (12px), text-sm (14px), text-base (16px), text-lg (18px), text-xl (20px), text-2xl (24px), text-3xl (30px), text-4xl (36px), text-5xl (48px), text-6xl (60px), text-7xl (72px), text-8xl (96px), text-9xl (128px).

Text align: text-left, text-center, text-right.

Font family: font-mono, font-sans, font-serif.

Line height: leading-none, leading-tight (1.25), leading-normal (1.5), leading-loose (2.0).

Text decoration: underline, line-through, no-underline, decoration-solid, decoration-wavy, decoration-none, decoration-2 (thickness).

Text overflow: truncate (overflow hidden + nowrap + ellipsis), text-ellipsis, text-clip.

Font style: italic, not-italic.

White space: whitespace-normal, whitespace-nowrap.

Borders

Border width: border (1px all), border-t, border-r, border-b, border-l, border-t-N, border-r-N, border-b-N, border-l-N.

Border style: border-solid, border-dashed.

Border color: border-{color}-{shade}, border-[#hex].

Border radius

rounded (4px), rounded-sm (2px), rounded-md (6px), rounded-lg (8px), rounded-xl (12px), rounded-2xl (16px), rounded-3xl (24px), rounded-full (9999px), rounded-none (0px).

Per-corner: rounded-tl, rounded-tr, rounded-bl, rounded-br, rounded-t, rounded-r, rounded-b, rounded-l (with optional size suffix).

Shadows

shadow-sm, shadow, shadow-md, shadow-lg, shadow-xl, shadow-2xl, shadow-none.

Cursor

cursor-pointer, cursor-default, cursor-text, cursor-not-allowed, cursor-grab, cursor-grabbing, cursor-crosshair.

Opacity

opacity-0 through opacity-100 (in increments of 5).

Inset

inset-0, inset-auto, top-N, right-N, bottom-N, left-N.

Grid

grid-cols-N, grid-rows-N, col-N, row-N.

Aspect ratio

aspect-square (1.0), aspect-video (16/9), aspect-N/M (arbitrary ratio).

Line clamp

line-clamp-N (1–10).

Z-index

z-0, z-10, z-20, z-30, z-40, z-50, z-auto.

Component Variants (variants!)

The variants! macro provides a declarative way to define component variant dimensions — for example, a Button with primary/secondary/danger colors and sm/md/lg sizes. Instead of writing manual if/else chains that select between css!{...} blocks, you declare the dimensions and their options once; the macro generates the enums, a combined Copy struct, and an ApplyStyle impl that applies the base style plus each selected dimension style sequentially.

Syntax

#![allow(unused)]
fn main() {
variants! {
    Button {
        base => css! {
            border-radius: 4px;
            cursor: pointer;
        },

        variant {
            primary => css! { background: #2563ff; color: #fff; },
            secondary => css! { background: #6c757d; color: #fff; },
            danger => css! { background: #dc2626; color: #fff; },
        },

        size {
            sm => css! { padding: 4px 8px; font-size: 12px; },
            md => css! { padding: 8px 16px; font-size: 14px; },
            lg => css! { padding: 12px 24px; font-size: 16px; },
        },
    }
}
}

Each entry inside the body is either:

  • base => <expr> — a style expression (typically css!{...}) applied to every instance. Optional; at most one.
  • <dimension> { <option> => <expr>, ... } — a dimension with named options, each mapping to a style expression. One or more dimensions.

The macro requires at least a base or one dimension. Each dimension must have at least one option.

Generated Types

For the Button definition above, the macro generates:

TypeDescription
enum ButtonVariant { Primary, Secondary, Danger }One enum per dimension; name = {Component}{Dimension} in PascalCase.
enum ButtonSize { Sm, Md, Lg }Option names are PascalCased (smSm).
struct ButtonVariants { pub variant: ButtonVariant, pub size: ButtonSize }Combined Copy struct; name = {Component}Variants.
impl Default for ButtonVariantsSelects the first option in each dimension.
impl ButtonVariants { fn variant(..), fn size(..) }Builder methods (one per dimension, consuming self).
impl ApplyStyle<E> for ButtonVariantsApplies base then each dimension’s selected style in order.

All generated enums and the struct derive Clone, Copy, PartialEq, Eq, Debug.

Usage with view!

The generated ButtonVariants struct implements ApplyStyle, so it drops directly into style={...} with no changes to the view! macro:

#![allow(unused)]
fn main() {
view! {
    <button style={ButtonVariants::default().variant(ButtonVariant::Danger).size(ButtonSize::Lg)}>
        {"Delete"}
    </button>
}
}

Default selects the first option per dimension, so ButtonVariants::default() gives you Primary + Sm. Chain builder methods to override:

#![allow(unused)]
fn main() {
ButtonVariants::default()
    .variant(ButtonVariant::Secondary)
    .size(ButtonSize::Md)
}

Usage in Component Structs

Store the enum values as fields on your component struct, then compose a ButtonVariants in the IntoElement impl:

#![allow(unused)]
fn main() {
pub struct Button {
    pub variant: ButtonVariant,
    pub size: ButtonSize,
    pub on_click: Box<dyn Fn(&mut gpui::App) + 'static>,
    pub children: Vec<gpui::AnyElement>,
}

impl gpui::IntoElement for Button {
    type Element = gpui::AnyElement;
    fn into_element(self) -> Self::Element {
        let variants = ButtonVariants::default()
            .variant(self.variant)
            .size(self.size);
        let on_click = self.on_click;
        let children = self.children;
        view! {
            <button style={variants} on:click={click(move |cx| on_click(cx))}>
                {vgui::for_each(children, |c, _| c)}
            </button>
        }
        .into_any_element()
    }
}
}

Edge Cases

  • No base: omit the base => ... line; the ApplyStyle impl applies only the dimension styles.
  • No dimensions (only base): generates a unit struct ButtonVariants; whose ApplyStyle applies only the base style.
  • Single dimension: works normally; the struct has one field.
  • Keyword names: if a dimension or option name is a Rust keyword (e.g. type, move), the macro emits raw identifiers (r#type for the field/ method, r#Type for the enum variant).

tw! Limitation

tw!("...") expressions work as variant style values, but only the base class is applied (via TwStyle’s ApplyStyle impl). Hover/focus/active states from tw! are not applied by the generated ApplyStyle impl. For interactive states, use css! for variant styles and the class/hover/focus/active attributes on the element itself.

Full Example

See examples/variants/src/main.rs for a complete dual-target example that renders a grid of buttons across all variant × size combinations.

Running

Native:

cargo run -p vgui-variants

Web (WASM):

cargo build --target wasm32-unknown-unknown -p vgui-variants --release
wasm-bindgen --target web --out-dir examples/variants/dist \
    --no-typescript target/wasm32-unknown-unknown/release/variants.wasm
python3 scripts/serve_plain.py 8080 examples/variants

CSS Property Reference

This page lists every CSS property supported by the css! macro, grouped by category. Properties not listed here produce a compile-time error.

Layout

PropertyValues
displayflex | block | none | grid
visibilityhidden | visible
overflowhidden | scroll | visible | clip | auto
overflow-xhidden | scroll | visible | clip | auto
overflow-yhidden | scroll | visible | clip | auto
positionrelative | absolute
flex-directionrow | column | row-reverse | column-reverse
flex-wrapnowrap | wrap | wrap-reverse
flexnone | auto | N (grow) | N N (grow shrink, basis 0px) | N N basis
flex-grownumber
flex-shrinknumber
flex-basislength
justify-contentflex-start | flex-end | center | space-between | space-around | space-evenly
align-itemsflex-start | flex-end | center | baseline | stretch
align-selfsame as align-items
align-contentflex-start | flex-end | center | space-between | space-around | stretch | space-evenly
gaplength | length length
row-gaplength
column-gaplength
grid-template-columnsnumber (column count)
grid-template-rowsnumber (row count)
grid-columnspan N | A / B | N
grid-column-startnumber
grid-column-endnumber
grid-rowspan N | A / B | N
grid-row-startnumber
grid-row-endnumber
grid-template-areasstring literals (rows of whitespace-separated cell names; . = empty cell). Infers grid-template-columns/-rows counts.
grid-area"name" (resolves against grid-template-areas) | N | row / col | row-start / col-start / row-end / col-end
scrollbar-widthauto | thin | none

Box Model

PropertyValues
widthlength
heightlength
min-widthlength
min-heightlength
max-widthlength
max-heightlength
paddinglength | length length | length length length length
padding-toplength
padding-rightlength
padding-bottomlength
padding-leftlength
padding-inlinelength (sets left + right)
padding-blocklength (sets top + bottom)
marginlength | length length | length length length length
margin-toplength
margin-rightlength
margin-bottomlength
margin-leftlength
margin-inlinelength (sets left + right)
margin-blocklength (sets top + bottom)
insetlength | length length | length length length length
toplength
rightlength
bottomlength
leftlength
aspect-rationumber

Visual

PropertyValues
backgroundcolor | linear-gradient(angle, from, to)
background-colorcolor
colorcolor
opacitynumber (0.0–1.0)
borderwidth style color (e.g. 1px solid #ccc)
border-topwidth style color (one side)
border-rightwidth style color (one side)
border-bottomwidth style color (one side)
border-leftwidth style color (one side)
border-colorcolor
border-stylesolid | dashed
border-widthlength
border-top-widthlength
border-right-widthlength
border-bottom-widthlength
border-left-widthlength
border-radiuslength
border-top-left-radiuslength
border-top-right-radiuslength
border-bottom-right-radiuslength
border-bottom-left-radiuslength
cursorpointer | default | text | crosshair | not-allowed | grab | grabbing
box-shadownone | sm | md | lg | xl | [inset]? <offset-x> <offset-y> <blur>? <spread>? <color>?

Text

PropertyValues
font-sizelength (not %)
font-weightthin | extra-light | light | normal | medium | semibold | bold | extrabold | black | 100–900
font-styleitalic | normal
font-familystring literal or keyword
text-alignleft | center | right
text-decorationunderline | line-through | none
text-decoration-colorcolor
text-decoration-thicknesslength
text-decoration-stylesolid | wavy
text-overflowellipsis | clip
text-backgroundcolor
text-background-colorcolor
white-spacenowrap | normal
line-heightnumber (unitless → relative) or length
line-clampnumber

Unsupported Properties

The following common CSS properties are not supported by css! because gpui’s styling model does not provide equivalents:

  • transform / translate / rotate / scale
  • transition / animation — use tw! transition-* / animate-* utilities instead. See Animations & Transitions.
  • z-index (use tw! z-N utilities instead)
  • box-sizing (gpui uses content-box semantics)
  • gap with more than 2 values
  • outline (use border instead)
  • list-style / list-style-type
  • background-image (use linear-gradient in background instead)
  • background-position / background-size / background-repeat
  • float / clear
  • @media queries / responsive breakpoints — use tw! responsive prefixes sm:/md:/lg:/xl: instead. See Tailwind Classes.
  • !important

If you need a property not listed here, check whether a tw! utility covers it, or use gpui’s styled element methods directly outside of css!.

Animations & Transitions

vgui provides Tailwind-compatible animation and transition utilities that map onto gpui’s animation engine. All configuration is parsed at compile time by the tw! macro (or at runtime via tw_dynamic), so there is zero per-frame parsing cost.

Keyframe Animations

Built-in animate-* classes run a repeating keyframe loop on the element:

ClassEffect
animate-pulseOpacity fades between 1.0 and 0.5 (2 s loop).
animate-bounceVertical margin offset ±8 px (1 s loop).
animate-pingOpacity 1.0 → 0 with slight scale (1 s loop).
animate-spinNo-op (gpui has no transform/rotation for divs).
#![allow(unused)]
fn main() {
view! {
    <div class="bg-blue-500 rounded p-4 animate-pulse">
        {"Loading…"}
    </div>
}
}

Custom animation via animate={...}

For full control, pass a closure to the animate attribute. The closure receives the element and must return an AnimationElement (via with_animation):

#![allow(unused)]
fn main() {
use std::time::Duration;
use gpui::AnimationExt;

view! {
    <div
        class="bg-amber-500 rounded p-4"
        animate={|el| el.with_animation(
            "breath",
            gpui::Animation::new(Duration::from_millis(1500))
                .repeat()
                .with_easing(gpui::ease_in_out),
            |mut el, delta| {
                el = el.opacity(0.4 + 0.6 * (delta * std::f32::consts::PI).sin());
                el
            },
        )}
    >
        {"Custom breathing"}
    </div>
}
}

Transitions

Transitions animate style changes when an element enters or leaves the hover state. Declare a transition-* class alongside a hover: variant:

ClassProperties interpolated
transitionopacity, color, background, margin, padding
transition-opacityopacity only
transition-colorstext color, background
transition-allall animatable properties
#![allow(unused)]
fn main() {
view! {
    <button class="bg-indigo-500 hover:opacity-50 rounded p-3 transition-opacity duration-300">
        {"Fade on hover"}
    </button>
}
}

Timing Modifiers

ModifierExampleEffect
duration-*duration-300Transition duration in ms.
ease-*ease-in-outEasing function (linear, ease-in, ease-out, ease-in-out, ease-bounce).
delay-*delay-100Stored but not applied (gpui has no native delay).
#![allow(unused)]
fn main() {
view! {
    <button class="bg-indigo-500 hover:bg-blue-600 rounded p-3 transition-colors duration-300 ease-in-out">
        {"Color on hover"}
    </button>
}
}

How It Works

  1. Compile time — the tw! proc-macro parses animate-*, transition-*, duration-*, ease-*, and delay-* classes and emits TwAnimation / TwTransition structs inside TwStyle.
  2. view! macro — when a class attribute is present, the macro destructures TwStyle and calls apply_animation / apply_transition after children are attached.
  3. apply_transition — creates a hover signal, registers on_hover, and uses gpui’s with_animation to interpolate between the base and hover StyleRefinement snapshots.

Limitations

  • animate-spin is a no-op — gpui does not support CSS transforms/rotation on div elements.
  • Animation + transition on the same element — when both are present, animation takes priority. gpui’s AnimationElement does not implement Styled, so a transition (whose animator needs Styled) cannot wrap it.
  • delay-* is parsed and stored for API completeness but has no effect; gpui’s animation API has no native delay support.

Built-in HTML Elements

The view! macro maps lowercase HTML tags to gpui element builders. Most elements expand to gpui::div() with appropriate default styling. This page lists every supported tag and its behavior.

Container Elements

All of these expand to gpui::div() with no additional default styling:

| Tag | Notes | | <div>, <span>, <p>, <output> | Generic containers. | | <header>, <footer>, <nav>, <main> | Semantic sectioning. | | <section>, <article>, <aside>, <address> | Semantic sectioning. | | <form> | Form context: on:submit / on:reset + child submit/reset buttons. | | <fieldset>, <legend> | Form containers (pure div aliases). | | <figure>, <figcaption> | Figure containers. | | <pre>, <blockquote>, <q> | Text containers. |

Text Elements

TagDefault styling
<h1>font-size 2.0rem, font-weight 600
<h2>font-size 1.5rem, font-weight 600
<h3>font-size 1.25rem, font-weight 600
<h4>font-size 1.0rem, font-weight 600
<h5>font-size 0.875rem, font-weight 600
<h6>font-size 0.85rem, font-weight 500
<strong>, <b>font-weight: bold
<em>, <i>font-style: italic
<u>text-decoration: underline
<s>, <del>, <strike>text-decoration: line-through
<mark>background: yellow
<small>font-size: 0.875rem
<code>, <kbd>, <samp>, <var>font-family: monospace
<cite>, <abbr>, <dfn>, <bdi>, <bdo>, <time>Plain div (no defaults)

List Elements

TagDefault styling
<ul>flex column
<ol>flex column
<li>plain div
<dl>flex column
<dt>font-weight: bold
<dd>padding-left: 16px

gpui has no list-style; draw numbering or bullets with text prefixes yourself.

TagDefault styling
<a>cursor: pointer, text-color: blue (hsla 220, 100%, 50%)

<a> supports the href attribute (accepted but not navigable in vgui v1 — use on:click for navigation actions).

Button Elements

<button> expands to gpui::div().cursor_pointer() and defaults to tabindex={0} so it is in Tab order. An explicit tabindex overrides that default. Keyboard activation of on:click is handled by gpui.

Media Elements

TagAttributesBehavior
<img>src (required), object_fit, alt, on:load, on:errorgpui::img(src). object_fit accepts fill, contain, cover, scale-down, none. alt is accepted and unused for a11y. on:load/on:error take Fn(&mut App) and fire when the image finishes loading or fails.
<svg>src (required)gpui::svg().path(src)

Void elements

TagBehavior
<br>A 1-line-height empty div.
<hr>Full-width 1px gray divider.
<wbr>Renders nothing (gpui::Empty).

No-op elements

<colgroup> and <col> are accepted (they compile) but render nothing (gpui::Empty). Column widths are controlled per-cell via class or style.

<datalist> renders nothing (gpui::Empty) but registers its id and options in a thread-local map so text inputs with list=<id> can show autocomplete suggestions. It requires an id attribute and accepts options={Vec<String>}. See Input Elements.

<option> and <optgroup> are accepted as standalone tags (they compile as pure <div> aliases) but are not read by <select>. Use the options or groups prop on <select> instead.

Tables

Tables use a flex-based layout — gpui has no native table layout:

TagLayout behavior
<table>flex column
<thead>flex column
<tbody>flex column
<tfoot>flex column
<caption>plain div
<tr>flex row, full width
<td>flex_1 (shares row width equally)
<th>flex_1, bold, centered text

colspan on <td>/<th> is mapped to flex_grow, so a cell with colspan={2} grows 2× relative to colspan=1 cells. rowspan is accepted but has no visual effect.

#![allow(unused)]
fn main() {
view! {
    <table class="w-full">
        <thead>
            <tr class="bg-[#333]">
                <th class="p-2 text-white">{"Name"}</th>
                <th class="p-2 text-white">{"Age"}</th>
            </tr>
        </thead>
        <tbody>
            <tr>
                <td class="p-2">{"Alice"}</td>
                <td class="p-2">{"30"}</td>
            </tr>
            <tr>
                <td class="p-2" colspan={2u32}>{"Bob (spanned)"}</td>
            </tr>
        </tbody>
    </table>
}
}

For specific column widths, apply class="w-[200px]" or a style width on individual cells.

Common Attributes

All built-in elements support these attributes:

AttributeSyntaxDescription
stylestyle={css!{...}}CSS-in-Rust styles.
classclass="..."Tailwind utility classes (expanded via tw!).
hoverhover={css!{...}}Styles applied on mouse hover.
activeactive={css!{...}}Styles applied on mouse-down.
focusfocus={css!{...}}Styles applied on keyboard focus.
idid="my-id"Sets the gpui element id.
tabindextabindex={0}≥0 sets tab order; <0 is focusable only.
on:eventon:click={handler}Event handler (see view! macro).
refref={node_ref}Binds a NodeRef handle for imperative ops (focus, scroll, bounds). See Refs.
rolerole="button"Sets ARIA role via __resolve_aria_role.
aria:namearia:label="..."Sets ARIA attribute (aria:label, aria:description, aria:keyshortcuts, aria:selected, aria:expanded, aria:toggled, aria:valuenow/aria:numeric_value, aria:value, aria:placeholder, aria:numeric_value_step).

Elements that have on:click, hover, active, focus, class, tabindex, or ref but no explicit id automatically receive a stable auto-generated id (see Auto IDs).

Radio Group

<radiogroup> wraps child <input type="radio"> elements and enables roving tabindex with arrow-key navigation. It has no attributes — it establishes a scope (via __radiogroup_scope_enter/__radiogroup_scope_exit) that child radio buttons read to coordinate focus and selection. See Input Elements for details.

Form

<form> wraps children in a form context. The on:submit and on:reset attributes take FnMut(&mut App) closures. Child submit/reset buttons auto-invoke the form handler when activated. Pressing Enter in a single-line text input triggers on:submit. See Input Elements for details.

Control Flow

vgui provides four control-flow constructs in view!: <Show> for conditional rendering, <For> for list rendering, <Switch>/<Match> for multi-branch conditional branching, and <Index> for keyed-by-position list rendering with per-item state. These are special-cased by the view! macro and expand to function calls in the vgui crate.

<Show> — Conditional Rendering

<Show> conditionally renders its children based on a when boolean expression. An optional fallback renders when the condition is false.

Without fallback

When when is false, renders nothing (gpui::Empty):

#![allow(unused)]
fn main() {
view! {
    <Show when={count.get() > 0}>
        <span>{"positive"}</span>
    </Show>
}
}

Expands to:

#![allow(unused)]
fn main() {
vgui::show_when(count.get() > 0, { <span>{"positive"}</span> })
}

With fallback

When when is false, renders the fallback:

#![allow(unused)]
fn main() {
view! {
    <Show when={count.get() & 1 == 1} fallback={view! { <span>{"even"}</span> }}>
        <span>{"odd"}</span>
    </Show>
}
}

Expands to:

#![allow(unused)]
fn main() {
vgui::show(count.get() & 1 == 1, { <span>{"odd"}</span> }, { <span>{"even"}</span> })
}

Required attributes

AttributeTypeRequiredDescription
whenboolYesCondition expression.
fallbackelementNoElement when when is false.

No other attributes are accepted on <Show> — the macro produces a compile error for unsupported attributes.

Multiple children

<Show> can have multiple children; they are added directly to the parent element:

#![allow(unused)]
fn main() {
<div class="flex flex-col">
    <Show when={is_loading.get()}>
        <span>{"Loading..."}</span>
        <progress value={0.5f64} max={1.0f64} />
    </Show>
</div>
}

Both <span> and <progress> become direct flex children of the <div>.

<For> — List Rendering

<For> iterates over a collection and renders a closure for each item. An optional fallback renders when the collection is empty.

Basic usage

#![allow(unused)]
fn main() {
view! {
    <For each={todos.get()}>
        {move |todo: Todo, _i: usize| todo_item(todo)}
    </For>
}
}

Expands to:

#![allow(unused)]
fn main() {
vgui::for_each(todos.get(), move |todo: Todo, _i: usize| todo_item(todo))
}

With fallback

#![allow(unused)]
fn main() {
view! {
    <For each={visible_todos.get()} fallback={view! {
        <div>{"No todos here."}</div>
    }}>
        {move |todo: Todo, _i: usize| todo_item(todo, set_todos.clone())}
    </For>
}
}

Expands to:

#![allow(unused)]
fn main() {
vgui::for_each_or(visible_todos.get(), { <div>{"No todos here."}</div> }, move |todo, _i| todo_item(todo, set_todos.clone()))
}

Required attributes and children

AttributeTypeRequiredDescription
eachimpl IntoIteratorYesThe collection to iterate.
fallbackelementNoElement when collection is empty.

The child of <For> must be exactly one interpolation {...} containing a closure. The closure signature is move |item: T, index: usize| -> impl IntoElement.

#![allow(unused)]
fn main() {
{move |item: ItemType, index: usize| view! { <div>{format!("Item {}", item.name)}</div> }}
}

How it works

The macro emits a for loop that calls parent.child(closure(item, i)) for each item. When the iterator is empty and no fallback is provided, nothing is added. When a fallback is provided and the iterator is empty, the fallback element is added as a single child.

Note: <For> re-renders all items on every render — it does not key-track individual items for minimal diffing. The closure receives (item, index), and each invocation produces a fresh element. Stateful widgets inside the closure (text inputs, sliders) are persisted via the reactive scope slot mechanism, which assigns stable ids based on call order.

<Switch>/<Match> — Multi-Branch Conditional

<Switch> renders the first <Match> child whose when condition is true. An optional fallback renders when no branch matches. Each branch gets its own persistent child scope, so signals/memos/effects created inside a branch survive across re-renders as long as that branch remains active. When the active branch changes, the previously active branch’s scope is disposed and its on_cleanup callbacks run.

Basic usage

#![allow(unused)]
fn main() {
view! {
    <Switch fallback={view! { <div>{"no match"}</div> }}>
        <Match when={status.get() == "loading"}>
            <div>{"Loading..."}</div>
        </Match>
        <Match when={status.get() == "error"}>
            <div>{"Error!"}</div>
        </Match>
        <Match when={status.get() == "ready"}>
            <div>{"Ready"}</div>
        </Match>
    </Switch>
}
}

The when expressions are evaluated in order; the first true branch wins (short-circuit). If none match, the fallback renders. When fallback is omitted, gpui::Empty is rendered.

Without fallback

#![allow(unused)]
fn main() {
view! {
    <Switch>
        <Match when={count.get() == 0}>
            <div>{"zero"}</div>
        </Match>
        <Match when={count.get() > 0}>
            <div>{"positive"}</div>
        </Match>
    </Switch>
}
}

Per-branch scope isolation

Each <Match> branch runs inside its own child scope (keyed by switch:{id}:{branch_index}). This means:

  • create_signal / create_memo / create_effect calls inside a branch are slot-cached per-branch, not per-render.
  • State persists across re-renders as long as the same branch stays active.
  • When the active branch changes, the old branch’s scope is disposed: on_cleanup callbacks run, signals/memos/effects are dropped.
#![allow(unused)]
fn main() {
view! {
    <Switch>
        <Match when={mode.get() == "edit"}>
            // This signal persists as long as the "edit" branch is active.
            // When the user switches to another branch, the scope is
            // disposed and on_cleanup runs.
            {|| {
                let (draft, set_draft) = create_signal(String::new());
                on_cleanup(move || eprintln!("edit scope disposed"));
                view! {
                    <input type="text" value={draft.get()} on:input={input_cb(move |v, _| set_draft.set(v.clone()))} />
                }
            }}
        </Match>
        <Match when={mode.get() == "view"}>
            <div>{"Read-only view"}</div>
        </Match>
    </Switch>
}
}

Required attributes and children

ElementAttributeTypeRequiredDescription
<Switch>fallbackelementNoElement when no <Match> matches.
<Match>whenboolYesCondition expression.

<Switch> children must be <Match> elements only — any other child node produces a compile error. <Match> accepts only the when attribute; other attributes produce a compile error.

How it works

The macro expands to:

#![allow(unused)]
fn main() {
{
    let __switch_id = vgui::next_auto_id();
    let __active: Option<usize> =
        if status.get() == "loading" { Some(0) }
        else if status.get() == "error" { Some(1) }
        else if status.get() == "ready" { Some(2) }
        else { None };
    vgui::__switch_dispose_inactive(__switch_id, __active, 3);
    match __active {
        Some(0) => {
            vgui::__switch_enter_branch(__switch_id, 0);
            parent = parent.child(<div>{"Loading..."}</div>);
            vgui::__switch_exit_branch();
        }
        // ... other arms
        _ => { /* fallback or nothing */ }
    }
}
}

__switch_dispose_inactive removes and disposes child scopes for all non-active branches before entering the active one. __switch_enter_branch / __switch_exit_branch wrap the branch body in a child scope.

<Index> — Keyed-by-Position List

<Index> iterates over a collection and renders a closure for each item. Unlike <For>, each item gets its own persistent child scope (keyed by index:{list_id}:{position}), so state created inside the closure (signals, memos, effects) survives re-renders as long as the item remains at the same position. When the list shrinks, excess scopes are disposed and their on_cleanup callbacks run.

Basic usage

#![allow(unused)]
fn main() {
view! {
    <Index each={items.get()}>
        {move |item: String, i: usize| view! {
            <div>{format!("Item {}: {}", i, item)}</div>
        }}
    </Index>
}
}

Expands to:

#![allow(unused)]
fn main() {
vgui::index_list(items.get(), move |item: String, i: usize| view! {
    <div>{format!("Item {}: {}", i, item)}</div>
})
}

With fallback

#![allow(unused)]
fn main() {
view! {
    <Index each={visible.get()} fallback={view! {
        <div>{"No items."}</div>
    }}>
        {move |item: Item, i: usize| item_row(item, i)}
    </Index>
}
}

Required attributes and children

AttributeTypeRequiredDescription
eachimpl IntoIteratorYesThe collection to iterate.
fallbackelementNoElement when collection is empty.

The child of <Index> must be exactly one interpolation {...} containing a closure. The closure signature is move |item: T, index: usize| -> impl IntoElement.

<For> vs <Index>

Feature<For><Index>
Per-item scopeNo (shared parent scope)Yes (child scope per position)
State persistenceVia slot order in parent scopeVia dedicated child scope per position
Disposal on shrinkNoYes (on_cleanup runs)
Use caseSimple lists, stateless itemsLists with per-item state (inputs, toggles)

<For> is lighter-weight — items share the parent’s reactive scope and rely on slot-call-order for state identity. <Index> is heavier but gives each item its own isolated scope, making it suitable for lists where each item has independent stateful widgets (text inputs, checkboxes) that should be cleaned up when the item is removed.

How it works

The macro assigns a unique list_id via next_auto_id(), then for each item enters a child scope keyed by index:{list_id}:{position} before calling the closure and exits it after. After all items are rendered, __index_dispose_excess(list_id, n) removes and disposes child scopes for positions >= n (items that no longer exist).

Input Elements

Overview

<input> is a void element — it cannot have children and is configured entirely through attributes. Both <input type="text"> and <input type="text" /> (self-closing) are accepted.

The type attribute selects the widget kind. If omitted, type="text" is assumed. Supported types:

CategoryTypes
Text-basedtext, password, search, email, url, tel, number, date, datetime-local, time, month, week, color
Booleancheckbox, radio
Numericrange
Filefile
Buttonsubmit, button, reset
Hiddenhidden

Text-based Types

All text-based types render a full-featured text field with cursor movement, selection, keyboard editing, clipboard (Ctrl+A/C/V/X), and IME (CJK composition) support.

#![allow(unused)]
fn main() {
view! {
    <input
        type="text"
        placeholder="Name"
        on:input={move |v: &str, cx: &mut App| set_name.set(cx, v.to_string())}
    />
}
}

Supported attributes

AttributeApplies toDescription
valueAll text-basedInitial/current value (string).
placeholderAll text-basedPlaceholder text.
disabledAll text-basedDisables input.
readonlyAll text-basedRead-only mode.
minnumberMinimum numeric value (f64).
maxnumberMaximum numeric value (f64).
stepnumberStep increment (f64).
on:inputAll text-basedFires on every keystroke. FnMut(&str, &mut App).
on:changeAll text-basedFires on Enter/blur. FnMut(&str, &mut App).
styleAll text-basedCSS-in-Rust styles.
classAll text-basedTailwind classes.
idAll text-basedElement id (for <label for=>).
requiredAll text-basedFails validation when empty.
patternAll text-basedLiteral match or * wildcard (foo*, *bar). Not JS RegExp.
minlengthAll text-basedMinimum character count (usize).
maxlengthAll text-basedMaximum character count (usize).
listAll text-basedDatalist id for autocomplete suggestions.

Type-specific placeholder defaults

When no placeholder is specified, each type provides a format-appropriate default:

TypeDefault placeholder
text(empty)
password(empty)
searchSearch…
emailemail@example.com
urlhttps://example.com
tel+1 (555) 000-0000
number0
dateYYYY-MM-DD
datetime-localYYYY-MM-DDTHH:MM
timeHH:MM
monthYYYY-MM
weekYYYY-Www
color#RRGGBB

Note: date, datetime-local, time, month, and week render a calendar popup. color renders a preset color palette popup. tel is text-entry with format validation only.

Validation

Text-based inputs check constraint rules on every render and turn the border red (hsla(0, 0.8, 0.5, 1)) when the value is invalid. Validation does not block on:input or on:change — it is visual feedback only.

Rules applied (in order):

  1. required — value must not be empty.
  2. minlength / maxlength — character count bounds.
  3. pattern — exact literal match or a single * wildcard prefix/suffix (foo*, *bar, foo). This is not a JavaScript RegExp.
  4. Type-specific checks:
    • email — exactly one @ with non-empty local and domain parts.
    • url — must start with http:// or https://.
    • number — must parse as f64 and satisfy min/max if set.

Color picker

<input type="color"> displays a 24×full-height color swatch on the right side of the input, showing the current #RRGGBB value (gray on parse failure). Clicking the swatch opens an 8×3 preset palette of 24 colors. Clicking a preset writes the uppercase #RRGGBB value, closes the popup, and fires on:input + on:change. Free RGB sliders are not provided.

Checkbox & Radio

#![allow(unused)]
fn main() {
view! {
    <input type="checkbox"
        checked={done.get()}
        on:change={move |v: bool, cx: &mut App| set_done.set(cx, v)}
    />
}
}
#![allow(unused)]
fn main() {
view! {
    <input type="radio"
        checked={sel.get() == 0}
        on:change={move |_v: bool, cx: &mut App| set_sel.set(cx, 0)}
    />
}
}

Attributes

AttributeTypeDescription
checkedboolChecked state.
disabledboolDisables input.
on:changeFnMut(bool, &mut App)Fires on click. New checked state.
style/class/hover/active/focus/id/tabindexStandard styling attributes.

The checkbox renders as an 18×18px rounded box with a checkmark (✓) when checked. The radio renders as a similar box (radio-specific styling is planned).

Radio Groups (Roving Tabindex)

Wrap radios in a <radiogroup> to enable roving tabindex:

#![allow(unused)]
fn main() {
view! {
    <radiogroup>
        <input type="radio" checked={sel.get() == 0}
            on:change={move |_, cx| set_sel.set(cx, 0)} />
        <input type="radio" checked={sel.get() == 1}
            on:change={move |_, cx| set_sel.set(cx, 1)} />
        <input type="radio" checked={sel.get() == 2}
            on:change={move |_, cx| set_sel.set(cx, 2)} />
    </radiogroup>
}
}

Inside <radiogroup>:

  • Only the checked radio is a tab stop (Tab moves to it, then Tab again leaves the group).
  • Arrow keys (←/↑/→/↓) move focus between radios in the group.
  • Clicking a radio focuses it and fires on:change.

<radiogroup> takes no attributes. Radios outside a <radiogroup> behave as standalone focusable elements (the checked radio is a tab stop).

Range Slider

#![allow(unused)]
fn main() {
view! {
    <input type="range"
        min={0.0f64}
        max={100.0f64}
        step={1.0f64}
        value={vol.get()}
        on:change={move |v: f64, cx: &mut App| set_vol.set(cx, v)}
    />
}
}

Attributes

AttributeTypeDefaultDescription
minf640.0Minimum value.
maxf64100.0Maximum value.
stepf641.0Step increment.
valuef640.0Current value.
disabledboolfalseDisables the slider.
on:changeFnMut(f64, &mut App)Fires on drag. New value.
style/class/id/tabindexStandard styling.

The range slider is a persistent gpui entity — its drag state survives re-renders via the reactive scope slot mechanism.

File Picker

#![allow(unused)]
fn main() {
view! {
    <input type="file"
        value="Browse..."
        multiple={true}
        on:change={move |paths: Vec<std::path::PathBuf>, _cx: &mut App| {
            eprintln!("selected: {:?}", paths);
        }}
    />
}
}

Attributes

AttributeTypeDescription
valuestringButton label text.
multipleboolAllow multiple file selection.
on:changeFnMut(Vec<PathBuf>, &mut App)Fires on file selection.
style/class/hover/active/focus/id/tabindexStandard styling.

accept and name are accepted but unused in v1.

Select

<select> renders a dropdown with a popover option list. The options attribute is a Vec<(String, String)> of value-label pairs. The popover width always matches the trigger width; clicking the trigger toggles the popover, clicking an option fires on:change and closes it, and clicking outside or pressing Escape closes it.

#![allow(unused)]
fn main() {
view! {
    <select
        options={vec![
            ("1".to_string(), "One".to_string()),
            ("2".to_string(), "Two".to_string()),
        ]}
        value={sel.get()}
        on:change={move |v: &str, cx: &mut App| set_sel.set(cx, v.to_string())}
    />
}
}

Custom option content

To render rich content per option (icons, colored values, multi-line rows), pass a single closure child. The closure receives (value: &str, label: &str) and returns an element via view! {}. The same closure renders both the popover rows and the trigger’s display of the selected option.

#![allow(unused)]
fn main() {
view! {
    <select
        options={vec![
            ("1".to_string(), "One".to_string()),
            ("2".to_string(), "Two".to_string()),
        ]}
        value={sel.get()}
        on:change={move |v: &str, cx: &mut App| set_sel.set(cx, v.to_string())}
    >
        {move |value: &str, label: &str| view! {
            <div class="flex items-center gap-2">
                <span class="text-[#0f0]">{value.to_string()}</span>
                <span>{label.to_string()}</span>
            </div>
        }}
    </select>
}
}

When no child closure is given, each option renders as plain label text.

Attributes

AttributeTypeDescription
optionsVec<(String, String)>Value-label pairs (flat list).
groupsVec<(String, Vec<(String, String)>)>Grouped options; group name + value-label pairs. When non-empty, the popover renders by group.
valueStringCurrently selected value. In multiple mode, comma-separated values (a,b).
multipleboolEnables multi-select. Default false.
disabledboolDisables the select.
on:changeFnMut(&str, &mut App)Fires on selection change. In multiple mode, passes the comma-separated string.
style/class/idStandard styling.

Multiple selection

When multiple={true}, clicking an option toggles that value in the comma-separated value string. The popover stays open after each toggle (click outside or press Escape to close). The trigger text shows the selected labels joined by ", " (empty if none).

#![allow(unused)]
fn main() {
view! {
    <select
        multiple={true}
        options={vec![
            ("a".to_string(), "Alpha".to_string()),
            ("b".to_string(), "Beta".to_string()),
        ]}
        value={sel.get()}
        on:change={move |v: &str, cx: &mut App| set_sel.set(cx, v.to_string())}
    />
}
}

Grouped options

Use groups instead of options to render options under non-clickable bold group headers. Each group is (group_name, Vec<(value, label)>). When groups is non-empty, the popover renders by group; flat options are ignored for rendering but still used for label lookups.

#![allow(unused)]
fn main() {
view! {
    <select
        groups={vec![
            ("Fruits".to_string(), vec![
                ("apple".to_string(), "Apple".to_string()),
                ("banana".to_string(), "Banana".to_string()),
            ]),
            ("Vegetables".to_string(), vec![
                ("carrot".to_string(), "Carrot".to_string()),
            ]),
        ]}
        value={sel.get()}
        on:change={move |v: &str, cx: &mut App| set_sel.set(cx, v.to_string())}
    />
}
}

<select> does not read <option> or <optgroup> child nodes; use the options or groups prop instead.

Child closure signature

The optional child is a closure Fn(&str, &str) -> impl IntoElement. The first argument is the option’s value, the second is its label. The closure must be Fn (not FnMut) because it is invoked from multiple render sites.

Datalist

<datalist> provides autocomplete suggestions for text inputs. It renders nothing but registers a list of options under an id. A text input with a matching list=<id> attribute shows prefix-matched suggestions (up to 8) below the input when focused and the value is non-empty. Clicking a suggestion writes it to the input and fires on:input + on:change.

#![allow(unused)]
fn main() {
view! {
    <datalist id="cities" options={vec!["Paris".to_string(), "London".to_string()]} />
    <input type="text" list="cities" on:input={move |v: &str, cx: &mut App| {}} />
}
}

<datalist> requires an id attribute and accepts options={Vec<String>}. It does not read <option> child nodes; use the options prop.

Output

<output> is a pure <div> alias — a generic container with no special behavior. Use it to display computed results.

Textarea

<textarea> is a void element (no children) configured through attributes:

#![allow(unused)]
fn main() {
view! {
    <textarea
        placeholder="Enter text"
        value={text.get()}
        on:input={move |v: &str, cx: &mut App| set_text.set(cx, v.to_string())}
    />
}
}

Attributes

AttributeTypeDescription
valueStringCurrent content.
placeholderstringPlaceholder text.
disabledboolDisables editing.
readonlyboolRead-only mode.
rowsu32Minimum visible line count.
on:inputFnMut(&str, &mut App)Fires on every keystroke.
on:changeFnMut(&str, &mut App)Fires on blur.
style/class/id/tabindexStandard styling.

name is accepted but unused. When rows is set, the textarea’s minimum height is rows times the line height plus 8px of vertical padding; without rows the minimum height is 80px. The textarea is a multi-line text input with the same cursor/selection/clipboard/IME support as text inputs.

Submit/Button/Reset

These render as clickable buttons (like <button>). The value attribute becomes the button label. on:click wires the handler. Without an explicit tabindex they default to tabindex={0} and are in Tab order.

#![allow(unused)]
fn main() {
view! {
    <input type="submit" value="Submit" on:click={click(move |_cx| {})} />
    <input type="button" value="Click" on:click={click(move |_cx| {})} />
    <input type="reset" value="Reset" on:click={click(move |_cx| {})} />
}
}

Note: Inside a <form>, submit and reset buttons automatically invoke the form’s on:submit / on:reset handler. An explicit on:click overrides this — the auto-bind is skipped to avoid double-firing.

Form

<form> wraps its children in a form context. on:submit and on:reset accept FnMut(&mut App) closures. Child <input type="submit"> and <input type="reset"> buttons auto-invoke the enclosing form’s handler; pressing Enter in a single-line text input also triggers on:submit.

#![allow(unused)]
fn main() {
view! {
    <form on:submit={move |cx| { /* ... */ }} on:reset={move |cx| { /* ... */ }}>
        <input type="text" required={true} />
        <input type="submit" value="Go" />
        <input type="reset" value="Clear" />
    </form>
}
}

When no <form> ancestor is present, submit/reset buttons are no-ops. <form> supports standard styling attributes (style, class, hover, active, focus, id, tabindex, ref) and event handlers.

Hidden

<input type="hidden"> renders nothing (gpui::Empty):

#![allow(unused)]
fn main() {
view! {
    <input type="hidden" value="invisible" />
}
}

Label

<label> associates text with an input for click-to-focus behavior. Two forms are supported:

Explicit for attribute

#![allow(unused)]
fn main() {
view! {
    <label for="username" class="text-sm">{"Username"}</label>
    <input type="text" id="username" placeholder="Enter username" />
}
}

Clicking the label focuses the input with id="username".

Wrapping label

#![allow(unused)]
fn main() {
view! {
    <label class="flex flex-col gap-1">
        <span class="text-sm">{"Wrapped input"}</span>
        <input type="text" placeholder="Click label to focus" />
    </label>
}
}

When <label> wraps an input, clicking anywhere on the label focuses the first focusable child input. This works with all input types: text-based inputs (text, password, email, …), textarea, range, checkbox, radio, file, and select.

<label> supports all standard styling attributes (style, class, hover, active, focus, id, tabindex) and event handlers.

Other Components

Beyond standard HTML elements and input widgets, vgui provides several specialized components that are accessible as lowercase tags in view!.

<progress> — Progress Bar

Renders a horizontal progress bar. The fill width is determined by value / max:

#![allow(unused)]
fn main() {
view! {
    <progress value={0.5f64} max={1.0f64} />
}
}

Attributes

AttributeTypeDefaultDescription
valuef640.0Current progress value.
maxf641.0Maximum value.

The bar is rendered as a gpui::Div with a filled portion proportional to value / max. Standard styling attributes (class, style) can be applied to customize appearance.

<meter> — Meter Gauge

Renders a horizontal gauge. Fill width is value between min and max. When low, high, and optimum are all omitted the fill is the same blue as <progress>. Otherwise values inside [low.unwrap_or(min), high.unwrap_or(max)] are green and values outside that range are red.

#![allow(unused)]
fn main() {
view! {
    <meter value={0.3f64} min={0f64} max={1f64} low={0.2f64} high={0.8f64} optimum={0.5f64} />
}
}

Attributes

AttributeTypeDefaultDescription
valuef640.0Current value.
minf640.0Minimum of the range.
maxf641.0Maximum of the range.
lowOption<f64>noneLower bound of the “good” range.
highOption<f64>noneUpper bound of the “good” range.
optimumOption<f64>nonePresence (with low/high) selects green/red coloring.

<details> — Collapsible Container

Renders a collapsible container with a summary header and hidden content. The open attribute controls content visibility; the summary is always visible.

#![allow(unused)]
fn main() {
view! {
    <details open={open.get()}>
        <summary on:click={click(move |cx| set_open.update(cx, |v| *v = !*v))}>
            {"Click to toggle"}
        </summary>
        <div>{"Hidden content"}</div>
    </details>
}
}

Attributes

AttributeTypeDefaultDescription
openboolfalseWhether content is visible.

Children

<details> children are split: the first child (typically <summary>) is the always-visible header, and the remaining children form the collapsible content. <summary> is rendered as a gpui::div() with cursor_pointer().

Note: In vgui v1, open is a prop, not internal state — you must manage it with a signal and toggle it in the <summary> click handler, as shown above.

<dialog> — Modal Dialog

Renders a modal dialog that floats above all non-deferred content via a portal (deferred paint layer) at z-index priority 100. When open is false, renders a hidden element with no layout impact.

#![allow(unused)]
fn main() {
view! {
    <button on:click={click(move |cx| set_show.set(cx, true))}>
        {"Open Dialog"}
    </button>
    <dialog open={show_dialog.get()} on:close={move |cx| set_show.set(cx, false)}>
        <div class="bg-white p-4 rounded text-black">
            <p>{"Dialog content — click outside or press Escape to close."}</p>
            <button on:click={click(move |cx| set_show.set(cx, false))}>
                {"Close"}
            </button>
        </div>
    </dialog>
}
}

Attributes

AttributeTypeDefaultDescription
openboolfalseWhether the dialog is visible.
on:closeFn(&mut App)no-opCalled when the dialog is dismissed.

Dismissal

The dialog can be dismissed three ways:

  • Click-outside: Clicking the backdrop (outside the content) fires on:close. The backdrop occludes mouse events, so elements behind the dialog never receive them.
  • Escape key: Pressing Escape fires on:close — but only when focus is within the dialog. This is a gpui constraint: key events dispatch only along the focus path (root → focused node). Clicking into the dialog moves focus there, so Escape works in the natural case. If focus remains on background content, Escape will not fire.
  • Explicit close: Call your on:close handler from a button inside the dialog (as shown above).

The on:close closure takes Fn(&mut App) directly — no click() wrapper needed, unlike on:click. This is because on:close is a vgui abstraction, not a gpui event.

Focus Management

The dialog implements two accessibility features:

  • Focus trap: Tab and Shift+Tab cycle within the dialog content. Focus cannot escape to background elements.
  • Focus restore: When the dialog opens, the previously focused element is saved. When the dialog closes (via Escape, click-outside, or on:close), focus is restored to that element.

Focus moves to the dialog content when it opens. The trap and restore are automatic — no extra attributes or callbacks are needed.

Portal rendering

The dialog paints on a deferred layer (priority 100), so it floats above all non-deferred content regardless of sibling paint order. It stays centered even while the page scrolls — the portal layer is independent of scroll content.

<portal> — Portal Floating Layer

Renders content on a floating layer drawn after all non-deferred ancestors. This is the base portal primitive — dialog and floating wrap it with higher-level behavior.

#![allow(unused)]
fn main() {
view! {
    <portal priority={200}>
        <div class="bg-white p-2 rounded">
            {"Rendered on top of everything"}
        </div>
    </portal>
}
}

Attributes

AttributeTypeDefaultDescription
priorityusize0Stacking order; higher values paint on top of lower ones.

Use <portal> when you need custom stacking control — for example, rendering a dialog above another dialog (priority 100) by wrapping content at priority 200.

<floating> — Positioned Floating Element

Renders content at a window-coordinate point with automatic overflow avoidance. If the content would extend past the window edge, it snaps inside with an 8px margin. Paints on a deferred layer at priority 50 (below dialog’s 100).

#![allow(unused)]
fn main() {
view! {
    <floating position={gpui::point(gpui::px(100.), gpui::px(200.))}>
        <div class="bg-white p-2 rounded"
             on:mouse_down_out={move |_, _, cx| /* dismiss */}>
            {"Floating tooltip or popover"}
        </div>
    </floating>
}
}

Attributes

AttributeTypeDefaultDescription
positionPoint<Pixels>Window-coordinate position (required).

<floating> has no built-in dismissal. Add on:mouse_down_out to the content for click-outside behavior, as shown above.

Refs & NodeRef

vgui provides a SolidJS-style ref system that lets you obtain a handle to a rendered element for imperative operations — focus management, scroll control, and layout measurement.

Why refs?

gpui uses an immediate-mode element model: the entire element tree is rebuilt every frame and dropped before the next. There is no persistent DOM tree, so SolidJS-style ref (which returns a persistent HTMLElement) has no direct equivalent.

However, gpui provides persistent handles that survive across frames: FocusHandle (focus state) and ScrollHandle (scroll offset + element bounds

  • child bounds). NodeRef wraps both.

Creating a NodeRef

#![allow(unused)]
fn main() {
use vgui::prelude::*;

let my_ref = NodeRef::new();
}

NodeRef::new() creates an empty shell. The handle is unbound until it’s used as a ref= attribute in view!. Calling any method before the first render panics with a clear message — mirroring SolidJS where ref is undefined until mount.

Binding with ref=

#![allow(unused)]
fn main() {
let my_ref = NodeRef::new();

view! {
    <div ref={my_ref.clone()}>
        {"content"}
    </div>
}
}

The ref= attribute:

  1. Forces an auto-id if the element has no explicit id (required for track_focus / track_scroll, which need a StatefulInteractiveElement).
  2. Calls __bind_ref to cache a FocusHandle + ScrollHandle in the reactive scope slot, populating the NodeRef on first render and reusing them on subsequent renders.
  3. Applies track_focus(&handle) and track_scroll(&handle) to the element so gpui keeps the handles in sync across frames.

With explicit id

#![allow(unused)]
fn main() {
<div id="my-list" ref={my_ref.clone()}>
}

The explicit id is preserved — ref= does not overwrite it.

Methods

Once bound, NodeRef exposes these imperative methods:

MethodSignatureDescription
focus(&self, &mut Window, &mut App)Move keyboard focus to the element.
is_focused(&self, &Window) -> boolWhether the element is currently focused.
contains_focused(&self, &Window, &App) -> boolWhether the element contains the focused element.
bounds(&self) -> Bounds<Pixels>Painted bounds from the previous frame.
scroll_offset(&self) -> Point<Pixels>Current scroll offset.
scroll_to(&self, ix: usize)Scroll so child ix is visible (minimal scroll).
scroll_to_top(&self, ix: usize)Scroll so child ix is the first visible element.
scroll_to_bottom(&self)Scroll to the bottom of the content.
set_scroll_offset(&self, Point<Pixels>)Set the scroll offset explicitly.
child_bounds(&self, ix: usize) -> Option<Bounds<Pixels>>Painted bounds of child ix.
child_count(&self) -> usizeNumber of tracked children.
focus_handle(&self) -> FocusHandleClone the underlying focus handle.
scroll_handle(&self) -> ScrollHandleClone the underlying scroll handle.

All methods read state from the previous frame — sufficient for layout calculations and imperative actions.

Usage in event handlers

NodeRef is Clone (internally Rc<RefCell<...>>). Clone it before passing into closures so the ref= attribute can consume its own clone:

#![allow(unused)]
fn main() {
let scroll_ref = NodeRef::new();
let btn_ref = scroll_ref.clone();

view! {
    <div ref={scroll_ref.clone()} class="overflow-y-scroll h-64">
        {/* ... scrollable items ... */}
    </div>
    <button on:click={click(move |_cx| {
        btn_ref.scroll_to_bottom();
    })}>
        {"Scroll to bottom"}
    </button>
}
}

Component support

Components (uppercase tags) support ref= on an opt-in basis. The component’s props struct must have a r#ref: NodeRef field:

#![allow(unused)]
fn main() {
struct MyList {
    r#ref: NodeRef,
    items: Vec<String>,
}

view! {
    <MyList r#ref={my_ref.clone()} items={data} />
}
}

Components without a r#ref field will produce a compile error if the user passes ref=.

Limitations

  • <select> and <textarea>: ref= is not supported directly. Use a wrapping <div ref={...}> around these elements instead.
  • <Show>, <For>, <Switch>, <Index>, <Provider>: ref= is rejected — these are logical (layout-transparent) nodes with no rendered target element.
  • <input type="text"> and <input type="range">: These return Entity-backed views, not divs. ref= is not supported on them directly. For checkbox/radio/file/submit input types, ref= binds to the wrapper div.

Context & Provider

vgui provides a SolidJS-style context API for dependency injection. Values are provided by ancestor elements and consumed by descendants via a typed key, without prop drilling.

Context<T> — typed marker

Context<T> is a zero-sized, const-constructable typed marker. It is keyed by TypeId of T, so one context exists per type. Store it in a plain static:

#![allow(unused)]
fn main() {
use vgui::prelude::Context;

static THEME: Context<Theme> = Context::new();
}

Context<T> uses PhantomData<fn() -> T>, making it Copy/Clone and Send + Sync regardless of T — no bounds are required on T to live in a static. For multiple contexts of the same logical type, use newtype wrappers.

<Provider> — declarative provider

The <Provider> builtin pushes a value onto a thread-local stack before evaluating its children and pops it after. Descendants constructed between enter and exit observe the pushed value.

#![allow(unused)]
fn main() {
use vgui::prelude::*;
use vgui::view;

static THEME: Context<Theme> = Context::new();

fn app() -> impl gpui::IntoElement {
    let theme = Theme { /* ... */ };
    view! {
        <Provider context={THEME} value={theme}>
            <Child />
        </Provider>
    }
}
}

Both context and value attributes are required; other attributes are rejected. The context attribute takes a Context<T> and value takes the corresponding T (which must be Clone + 'static).

Consuming context

FunctionSignatureDescription
use_context(&Context&lt;T&gt;) -> Option&lt;T&gt;Returns the nearest ancestor provider’s value, or None if no provider is active.
use_context_or(&Context&lt;T&gt;, || T) -> TLike use_context, falling back to the supplied default when no provider is active.

Both walk the thread-local provider stack top-down and return the nearest matching entry.

#![allow(unused)]
fn main() {
fn child() -> impl gpui::IntoElement {
    let theme = use_context_or(&THEME, || Theme::default());
    view! {
        <div style={theme.background}>
            {"Themed content"}
        </div>
    }
}
}

provide_context — manual RAII provider

For use outside view! (tests, setup code), provide_context pushes a value and returns a ProviderGuard that pops the stack on drop:

#![allow(unused)]
fn main() {
static THEME: Context<Theme> = Context::new();

{
    let _guard = provide_context(&THEME, Theme::dark());
    // use_context(&THEME) returns Some(Theme::dark()) here
}
// guard dropped — stack popped
}

Nested override

An inner <Provider> with the same Context<T> shadows the outer provider within its subtree. Descendants between the inner enter/exit see the inner value; after the inner exit, the outer value is visible again.

Stack scope

The provider stack is per-render, not per-module. vgui renders synchronously, depth-first, in a single flat per-render scope — there is no nested owner tree. The view! macro emits __provider_scope_enter before evaluating children and __provider_scope_exit after, so the stack correctly reflects the element tree’s nesting at consumption time.

See the Context example for a complete working demonstration.

Custom Components

Any uppercase-tag element in view! is treated as a component call. The view! macro generates different code depending on whether the component has attributes and how many children it has.

Component Invocation

A component is any function or struct that produces an impl gpui::IntoElement. The tag name must start with an uppercase letter — lowercase tags are built-in HTML elements.

#![allow(unused)]
fn main() {
fn greeting(name: &'static str) -> impl gpui::IntoElement {
    view! { <span>{format!("Hello, {name}!")}</span> }
}

view! {
    <div>
        <Greeting name={"world"} />
    </div>
}
}

Without Attributes

No children

#![allow(unused)]
fn main() {
view! {
    <Greeting />
}
}

Expands to a function call with no arguments:

#![allow(unused)]
fn main() {
Greeting()
}

Single child

#![allow(unused)]
fn main() {
view! {
    <Greeting>{"world"}</Greeting>
}
}

Expands to:

#![allow(unused)]
fn main() {
Greeting(::vgui::into_child("world"))
}

Multiple children

#![allow(unused)]
fn main() {
view! {
    <Greeting>
        <span>{"Hello"}</span>
        <span>{"World"}</span>
    </Greeting>
}
}

Expands to a Vec of children:

#![allow(unused)]
fn main() {
Greeting(::std::vec![child1, child2])
}

With Attributes

When attributes are present, the macro generates a struct initializer. Each attribute maps to a struct field:

#![allow(unused)]
fn main() {
view! {
    <Greeting name={"world"} age={42} />
}
}

Expands to:

#![allow(unused)]
fn main() {
Greeting { name: "world", age: 42 }
}

With children:

#![allow(unused)]
fn main() {
view! {
    <Card title={"My Card"} class="p-4">
        <span>{"Content"}</span>
    </Card>
}
}

Expands to:

#![allow(unused)]
fn main() {
Card {
    title: "My Card",
    class: ::vgui::tw!("p-4"),
    children: ::std::vec![child1],
}
}

Attribute-to-field mapping

Attribute syntaxField nameNotes
name={value}nameDirect field assignment.
on:click={h}on_clickEvent attributes map to on_{event}.
on:mouse_down={h}on_mouse_down
style={css!{...}}style
class="..."class
hover={css!{...}}hover
active={css!{...}}active
focus={css!{...}}focus
id="my-id"id
src={path}src
type="text"r#typeRaw identifier (Rust keyword escaping).
tabindex={0}tabindex
for="id"r#forRaw identifier.
ref={node_ref}r#refOpt-in: component must have a r#ref: NodeRef field.

Event Mapping

Event attributes (on:event) are mapped to struct fields named on_{event}. This means your component struct must have a field named on_click to receive an on:click handler:

#![allow(unused)]
fn main() {
struct Button {
    on_click: impl Fn(&gpui::ClickEvent, &mut gpui::Window, &mut gpui::App),
    children: Vec<gpui::AnyElement>,
}

impl gpui::IntoElement for Button { /* ... */ }

view! {
    <Button on:click={click(move |_cx| {})}>{"Click"}</Button>
}
}

Expands to:

#![allow(unused)]
fn main() {
Button {
    on_click: click(move |_cx| {}),
    children: ::std::vec![::vgui::into_child("Click")],
}
}

Children

Children are always passed as the children field, which is a Vec<gpui::AnyElement>. Each child is wrapped via ::vgui::into_child().

If your component has no children, simply omit the children field from the struct initializer — but your struct must not require it.

Pattern: component with props and children

#![allow(unused)]
fn main() {
fn card(title: String, children: Vec<gpui::AnyElement>) -> impl gpui::IntoElement {
    view! {
        <div class="rounded-lg p-4 bg-white shadow">
            <h3 class="font-bold text-lg">{title}</h3>
            <div class="mt-2">
                {children}
            </div>
        </div>
    }
}

// But with attributes, you need a struct:
struct Card {
    title: String,
    children: Vec<gpui::AnyElement>,
}

impl gpui::IntoElement for Card {
    type Element = gpui::AnyElement;
    fn into_element(self) -> Self::Element {
        view! {
            <div class="rounded-lg p-4 bg-white shadow">
                <h3 class="font-bold text-lg">{self.title}</h3>
                <div class="mt-2">{self.children}</div>
            </div>
        }.into_element()
    }
}

view! {
    <Card title={"My Card".to_string()}>
        <span>{"Content here"}</span>
    </Card>
}
}

Tip: For simple components without attributes, prefer plain functions that take arguments and return impl IntoElement. Use struct initializers only when you need attribute-style syntax.

Spread & Rest Props

The {..expr} spread syntax forwards a props value onto a component or built-in element. This is vgui’s equivalent of SolidJS’s {...props} and enables the rest-props forwarding pattern.

Forwarding pattern

A wrapper component can forward its inner props sub-struct directly:

#![allow(unused)]
fn main() {
struct Outer {
    label: String,
    inner: Inner,
}

struct Inner {
    text: String,
    color: gpui::Hsla,
}

impl gpui::IntoElement for Outer {
    type Element = gpui::AnyElement;
    fn into_element(self) -> Self::Element {
        view! {
            <div>
                <span>{self.label}</span>
                <Inner {..self.inner} />
            </div>
        }
        .into_element()
    }
}
}

<Inner {..self.inner} /> expands to Inner { ..self.inner } — Rust’s struct update syntax. The entire inner field is moved into the Inner initializer.

Split-props pattern

Keep a “rest” sub-struct for forwarded props, separate from fields the wrapper consumes itself:

#![allow(unused)]
fn main() {
struct Card {
    title: String,          // consumed by Card
    rest: CardRest,         // forwarded to inner div
}

struct CardRest {
    class: String,
    style: vgui::Css,
}

impl gpui::IntoElement for Card {
    type Element = gpui::AnyElement;
    fn into_element(self) -> Self::Element {
        view! {
            <div {..self.rest}>
                <h3>{self.title}</h3>
            </div>
        }
        .into_element()
    }
}
}

Here <div {..self.rest} /> calls Spread<gpui::Div> (or Spread<gpui::Stateful<gpui::Div>> depending on other attributes). Implement Spread for CardRest to apply class and style onto the div.

Override pattern

Explicit fields always win over the spread, regardless of order:

#![allow(unused)]
fn main() {
view! { <Greeting {..props} name={"override"} /> }
// → Greeting { name: "override", ..props }
}

This is Rust’s struct update rule: named fields take precedence over ..base. Use it to override individual fields from a spread props value.

Limitations

  • One spread per element. Rust struct update syntax permits a single ..base, so only one {..expr} is allowed. To merge multiple props sources, construct a single merged struct in Rust.
  • Built-in spread requires a Spread<E> impl. See Spread Attributes in the view! macro reference for the trait definition and element-type rules.

Counter Example

Live Demo

Overview

The counter is the minimal vgui application. It demonstrates:

  • create_signal for state management.
  • create_memo for a derived value (doubled).
  • <Show> for conditional rendering (positive/negative/zero/odd/even).
  • Component functions taking ReadSignal and WriteSignal as props.
  • twc! macro for dynamic Tailwind class composition.
  • Tailwind classes via class= with hover: variants.

Source Code

#![cfg_attr(target_family = "wasm", no_main)]

use gpui::{px, size, App, Bounds, WindowBounds, WindowOptions};
use vgui::prelude::*;

#[cfg(not(target_family = "wasm"))]
use gpui_platform::application;

#[cfg(target_family = "wasm")]
use gpui_platform::single_threaded_web;

fn dynamic_button(
    label: String,
    count: ReadSignal<i32>,
    set_count: WriteSignal<i32>,
    delta: i32,
) -> impl gpui::IntoElement {
    view! {
        <button
            class={twc!(
                "p-2 rounded text-white",
                (delta > 0).then_some("bg-[#0000ff] hover:bg-[#000088]"),
                (delta < 0).then_some("bg-[#FF0000] hover:bg-[#880000]"),
                (count.get() == 0).then_some("bg-[#666666] hover:bg-[#444444]")
            )}
            on:click={click(move |cx| set_count.update(cx, |n| *n += delta))}
        >
            {label}
        </button>
    }
}

fn app() -> impl gpui::IntoElement {
    let (count, set_count) = create_signal(0i32);
    let doubled = create_memo({
        let count = count.clone();
        move || count.get() * 2
    });
    view! {
        <div class="flex flex-col gap-3 p-4 bg-[#505050] w-[500px] h-[500px] justify-center items-center text-white">
            <span>{format!("Hello, {}!", count.get())}</span>
            <span>{format!("doubled {}", doubled.get())}</span>
            <Show when={count.get() > 0}>
                <span>{"positive"}</span>
            </Show>
            <Show when={count.get() < 0}>
                <span>{"negative"}</span>
            </Show>
            <Show when={count.get() == 0}>
                <span>{"zero"}</span>
            </Show>
            <Show when={count.get() & 1 == 1} fallback={view! { <span>{"even"}</span> }}>
                <span>{"odd"}</span>
            </Show>
            {dynamic_button(String::from("Increment"), count.clone(), set_count.clone(), 1)}
            {dynamic_button(String::from("Decrement"), count.clone(), set_count.clone(), -1)}
        </div>
    }
}

fn run() {
    #[cfg(not(target_family = "wasm"))]
    let gpui_app = application();

    #[cfg(target_family = "wasm")]
    let gpui_app = single_threaded_web();

    let launch = |cx: &mut App| {
        let bounds = Bounds::centered(None, size(px(500.), px(500.0)), cx);
        cx.open_window(
            WindowOptions {
                window_bounds: Some(WindowBounds::Windowed(bounds)),
                ..Default::default()
            },
            |window, cx| vgui::mount(window, cx, app),
        )
        .unwrap();
    };

    #[cfg(not(target_family = "wasm"))]
    gpui_app.run(launch);

    #[cfg(target_family = "wasm")]
    std::mem::forget(gpui_app.run_embedded(launch));
}

#[cfg(not(target_family = "wasm"))]
fn main() {
    run();
}

#[cfg(target_family = "wasm")]
#[wasm_bindgen::prelude::wasm_bindgen(start)]
pub fn start() {
    gpui_platform::web_init();
    vgui::intercept_keyboard_events();
    run();
}

Key Concepts

Signal + memo

create_signal(0i32) creates the count state. create_memo derives doubled from it — the memo recomputes only when count changes, not on every render.

<Show> with and without fallback

Four <Show> blocks demonstrate both forms. The first three have no fallback (render nothing when false). The fourth uses fallback to show “even” when the count is not odd.

dynamic_button with twc!

A single dynamic_button function takes (label, count: ReadSignal, set_count: WriteSignal, delta) and returns impl IntoElement. The twc! macro composes conditional Tailwind classes at runtime: a base string plus Option<&str> arguments that are included only when their condition is true. The increment button (delta > 0) gets blue classes, the decrement button (delta < 0) gets red classes, and when the count is zero both get a neutral gray. Both buttons are created via interpolation {dynamic_button(...)} rather than uppercase tags, since they take multiple non-attribute arguments.

click helper

The click(move |cx| ...) helper wraps a simple Fn(&mut App) closure into the gpui event handler signature Fn(&ClickEvent, &mut Window, &mut App).

Tailwind classes with hover variants

class={twc!("p-2 rounded text-white", ...)} uses arbitrary color values, a hover: variant for the background color, and standard spacing/typography utilities.

Running

Native:

cargo run -p vgui-counter

Web (WASM):

# Build the WASM binary
cargo build --target wasm32-unknown-unknown -p vgui-counter --release

# Generate JS bindings
wasm-bindgen --target web --out-dir examples/counter/dist \
    --no-typescript target/wasm32-unknown-unknown/release/counter.wasm

# Serve and open in a browser
python3 scripts/serve_plain.py 8080 examples/counter

Todo List Example

Live Demo

Overview

The todo list is a larger application that demonstrates:

  • Vec<Todo> state managed with a signal.
  • create_memo for a filtered list (visible_todos) and a derived count (remaining).
  • <For> with a fallback for list rendering.
  • css! macro for styling (conditional styles based on state).
  • hover pseudo-state attribute.
  • Component functions (todo_item, filter_button).
  • Signal updates via .update(cx, |todos| ...).

Source Code

#![cfg_attr(target_family = "wasm", no_main)]

use gpui::{px, size, App, Bounds, WindowBounds, WindowOptions};
use vgui::prelude::*;

#[cfg(not(target_family = "wasm"))]
use gpui_platform::application;

#[cfg(target_family = "wasm")]
use gpui_platform::single_threaded_web;

#[derive(Clone, PartialEq)]
struct Todo {
    id: u32,
    text: String,
    done: bool,
}

fn todo_item(todo: Todo, set_todos: WriteSignal<Vec<Todo>>) -> impl gpui::IntoElement {
    let id = todo.id;
    let text = todo.text.clone();
    let done = todo.done;
    let text_style = if done {
        css! {
            color: #888;
            text-decoration: line-through;
            flex: 1;
            font-size: 14px;
        }
    } else {
        css! {
            color: #fff;
            flex: 1;
            font-size: 14px;
        }
    };
    let checkbox_style = if done {
        css! {
            width: 24px;
            height: 24px;
            border: 2px solid #888;
            border-radius: 4px;
            background: rgb(34, 197, 94);
            color: #fff;
            font-size: 14px;
            text-align: center;
            line-height: 20px;
        }
    } else {
        css! {
            width: 24px;
            height: 24px;
            border: 2px solid #888;
            border-radius: 4px;
            background: #333;
            color: #fff;
            font-size: 14px;
            text-align: center;
            line-height: 20px;
        }
    };
    let set_todos_toggle = set_todos.clone();
    view! {
        <div style={css! {
            display: flex;
            flex-direction: row;
            align-items: center;
            gap: 8px;
            padding: 8px 12px;
            background: #3a3a3a;
            border-radius: 4px;
        }}>
            <button
                style={checkbox_style}
                on:click={click(move |cx| {
                    set_todos_toggle.update(cx, |todos| {
                        if let Some(t) = todos.iter_mut().find(|t| t.id == id) {
                            t.done = !t.done;
                        }
                    });
                })}
            >
                {if done { "x" } else { "" }}
            </button>
            <span style={text_style}>
                {text}
            </span>
            <button
                style={css! {
                    padding: 4px 8px;
                    background: #dc2626;
                    border-width: 0px;
                    border-radius: 4px;
                    color: #fff;
                    font-size: 12px;
                }}
                hover={css! { background: #b91c1c; }}
                on:click={click(move |cx| {
                    set_todos.update(cx, |todos| {
                        todos.retain(|t| t.id != id);
                    });
                })}
            >
                {"Delete"}
            </button>
        </div>
    }
}

fn filter_button(
    label: &'static str,
    active: bool,
    on_click: impl Fn(&mut gpui::App) + 'static,
) -> impl gpui::IntoElement {
    let style = if active {
        css! {
            padding: 4px 12px;
            background: rgb(37, 83, 235);
            border-width: 0px;
            border-radius: 4px;
            color: #fff;
        }
    } else {
        css! {
            padding: 4px 12px;
            background: #444;
            border-width: 0px;
            border-radius: 4px;
            color: #fff;
        }
    };
    view! {
        <button
            style={style}
            on:click={click(on_click)}
        >
            {label}
        </button>
    }
}

fn app() -> impl gpui::IntoElement {
    let (todos, set_todos) = create_signal(vec![
        Todo {
            id: 0,
            text: "Learn vgui".into(),
            done: false,
        },
        Todo {
            id: 1,
            text: "Build a todo app".into(),
            done: false,
        },
        Todo {
            id: 2,
            text: "Ship it".into(),
            done: false,
        },
    ]);
    let (next_id, set_next_id) = create_signal(3u32);
    let (filter, set_filter) = create_signal("all".to_string());

    let remaining = create_memo({
        let todos = todos.clone();
        move || todos.get().iter().filter(|t| !t.done).count()
    });

    let visible_todos = create_memo({
        let todos = todos.clone();
        let filter = filter.clone();
        move || {
            let f = filter.get();
            let all = todos.get();
            match f.as_str() {
                "active" => all.into_iter().filter(|t| !t.done).collect::<Vec<_>>(),
                "completed" => all.into_iter().filter(|t| t.done).collect::<Vec<_>>(),
                _ => all,
            }
        }
    });

    let current_filter = filter.get();
    let set_todos_add = set_todos.clone();
    let set_todos_clear = set_todos.clone();
    let set_filter_all = set_filter.clone();
    let set_filter_active = set_filter.clone();
    let set_filter_completed = set_filter.clone();

    view! {
        <div style={css! {
            display: flex;
            flex-direction: column;
            gap: 12px;
            padding: 20px;
            background: rgb(30, 30, 30);
            width: 500px;
            height: 600px;
            color: #fff;
            font-size: 14px;
        }}>
            <span style={css! {
                font-size: 24px;
                font-weight: bold;
                text-align: center;
            }}>
                {"Todo List"}
            </span>

            <button
                style={css! {
                    padding: 8px 16px;
                    background: rgb(37, 83, 235);
                    border-width: 0px;
                    border-radius: 4px;
                    color: #fff;
                    font-size: 14px;
                    text-align: center;
                }}
                hover={css! { background: rgb(29, 78, 216); }}
                on:click={click(move |cx| {
                    let id = next_id.get_with(cx);
                    set_todos_add.update(cx, |todos| {
                        todos.push(Todo {
                            id,
                            text: format!("Task {}", id),
                            done: false,
                        });
                    });
                    set_next_id.update(cx, |n| *n += 1);
                })}
            >
                {"+ Add Todo"}
            </button>

            <div style={css! {
                display: flex;
                flex-direction: row;
                gap: 8px;
                justify-content: center;
            }}>
                {filter_button("All", current_filter == "all", move |cx| set_filter_all.set(cx, "all".to_string()))}
                {filter_button("Active", current_filter == "active", move |cx| set_filter_active.set(cx, "active".to_string()))}
                {filter_button("Completed", current_filter == "completed", move |cx| set_filter_completed.set(cx, "completed".to_string()))}
            </div>

            <div style={css! {
                display: flex;
                flex-direction: column;
                gap: 8px;
                flex: 1;
                overflow: hidden;
            }}>
                <For each={visible_todos.get()} fallback={view! {
                    <div style={css! {
                        text-align: center;
                        color: #888;
                        padding: 24px;
                    }}>
                        {"No todos here."}
                    </div>
                }}>
                    {move |todo: Todo, _i: usize| todo_item(todo, set_todos.clone())}
                </For>
            </div>

            <div style={css! {
                display: flex;
                flex-direction: row;
                justify-content: space-between;
                align-items: center;
                padding-top: 8px;
                border: 1px solid #444;
            }}>
                <span style={css! { color: #aaa; }}>
                    {format!("{} items left", remaining.get())}
                </span>
                <button
                    style={css! {
                        padding: 4px 12px;
                        background: #444;
                        border-width: 0px;
                        border-radius: 4px;
                        color: #ccc;
                        font-size: 12px;
                    }}
                    hover={css! { background: #555; }}
                    on:click={click(move |cx| {
                        set_todos_clear.update(cx, |todos| {
                            todos.retain(|t| !t.done);
                        });
                    })}
                >
                    {"Clear completed"}
                </button>
            </div>
        </div>
    }
}

fn run() {
    #[cfg(not(target_family = "wasm"))]
    let gpui_app = application();

    #[cfg(target_family = "wasm")]
    let gpui_app = single_threaded_web();

    let launch = |cx: &mut App| {
        let bounds = Bounds::centered(None, size(px(500.), px(600.0)), cx);
        cx.open_window(
            WindowOptions {
                window_bounds: Some(WindowBounds::Windowed(bounds)),
                ..Default::default()
            },
            |window, cx| vgui::mount(window, cx, app),
        )
        .unwrap();
    };

    #[cfg(not(target_family = "wasm"))]
    gpui_app.run(launch);

    #[cfg(target_family = "wasm")]
    std::mem::forget(gpui_app.run_embedded(launch));
}

#[cfg(not(target_family = "wasm"))]
fn main() {
    run();
}

#[cfg(target_family = "wasm")]
#[wasm_bindgen::prelude::wasm_bindgen(start)]
pub fn start() {
    gpui_platform::web_init();
    vgui::intercept_keyboard_events();
    run();
}

Key Concepts

Conditional css! styles

The todo_item function selects different css! blocks for the text and checkbox based on the done state — strikethrough + gray for completed, white for active.

Two memos: filtered list + count

visible_todos depends on both todos and filter — it recomputes when either changes. remaining depends only on todos.

<For> with fallback

When visible_todos is empty (e.g., filtering “active” with all todos done), the fallback “No todos here.” message renders.

hover pseudo-state

The delete and add buttons use hover={css! { ... }} to change the background color on mouse hover.

get_with for non-tracking reads

next_id.get_with(cx) reads the signal’s current value from the gpui entity without registering a dependency — needed because this read happens inside a click handler, not during render tracking.

Running

Native:

cargo run -p vgui-todolist

Web (WASM):

cargo build --target wasm32-unknown-unknown -p vgui-todolist --release
wasm-bindgen --target web --out-dir examples/todolist/dist \
    --no-typescript target/wasm32-unknown-unknown/release/todolist.wasm
python3 scripts/serve_plain.py 8080 examples/todolist

Styling Showcase Example

Live Demo

Overview

The styling showcase puts every vgui styling mechanism side by side in a single scrollable panel. It demonstrates:

  • css! macro with gradients, box-shadow, and arbitrary CSS properties.
  • Tailwind utility classes (tw!) including gradients and arbitrary color values.
  • Pseudo-state attributes: hover, active, and focus.
  • Dynamic class composition with twc! and conditional Option<&str> arguments.
  • Responsive breakpoints (sm: / lg:) that reflow on viewport resize.
  • Runtime-constructed class strings via tw_dynamic().

Source Code

#![cfg_attr(target_family = "wasm", no_main)]

use gpui::{px, size, App, Bounds, WindowBounds, WindowOptions};
use vgui::prelude::*;

#[cfg(not(target_family = "wasm"))]
use gpui_platform::application;

#[cfg(target_family = "wasm")]
use gpui_platform::single_threaded_web;

fn app() -> impl gpui::IntoElement {
    let (active, set_active) = create_signal(false);

    view! {
        <div class="flex flex-col gap-4 p-6 bg-[#1a1a2e] text-white" style={css!{ width: 700px; height: 600px; overflow-y: auto; }}>
            <h2 class="text-lg font-bold">{"Styling Showcase"}</h2>

            // ── css! macro ───────────────────────────────────────────
            <div class="flex flex-col gap-1">
                <span class="text-sm text-[#888]">{"css! macro"}</span>
                <div style={css!{
                    display: flex;
                    gap: 12px;
                    padding: 16px;
                    background: linear-gradient(135deg, #2563ff, #6c757d);
                    border-radius: 8px;
                    box-shadow: 0 4px 12px rgba(0,0,0,0.3);
                }}>
                    <div style={css!{ background: #2563ff; padding: 12px; border-radius: 4px; }}>
                        {"Child A"}
                    </div>
                    <div style={css!{ background: #6c757d; padding: 12px; border-radius: 4px; }}>
                        {"Child B"}
                    </div>
                </div>
            </div>

            // ── Tailwind classes ─────────────────────────────────────
            <div class="flex flex-col gap-1">
                <span class="text-sm text-[#888]">{"tw! classes"}</span>
                <div class="flex gap-3 p-4 bg-gradient-to-br from-[#2563ff] to-[#6c757d] rounded-lg">
                    <div class="bg-[#2563ff] p-3 rounded">
                        {"Child A"}
                    </div>
                    <div class="bg-[#6c757d] p-3 rounded">
                        {"Child B"}
                    </div>
                </div>
            </div>

            // ── Pseudo-states ────────────────────────────────────────
            <div class="flex flex-col gap-1">
                <span class="text-sm text-[#888]">{"Pseudo-states (hover / active / focus)"}</span>
                <div class="flex gap-2">
                    <button
                        class="px-4 py-2 bg-[#2563ff] text-white rounded"
                        hover={css!{ background: #0044cc; }}
                    >
                        {"Hover me"}
                    </button>
                    <button
                        class="px-4 py-2 bg-[#10b981] text-white rounded"
                        active={css!{ background: #34d399; }}
                    >
                        {"Active me"}
                    </button>
                    <button
                        class="px-4 py-2 bg-[#9933ff] text-white rounded"
                        focus={css!{ border: 2px solid #ffffff; }}
                    >
                        {"Focus me"}
                    </button>
                </div>
            </div>

            // ── Dynamic classes (twc!) ───────────────────────────────
            <div class="flex flex-col gap-1">
                <span class="text-sm text-[#888]">{"Dynamic classes (twc!)"}</span>
                <button
                    class={twc!(
                        "p-3 rounded text-white transition-colors",
                        active.get().then_some("bg-[#2563ff]"),
                        (!active.get()).then_some("bg-[#6c757d]")
                    )}
                    on:click={click(move |cx| set_active.update(cx, |v| *v = !*v))}
                >
                    {if active.get() { "Active: ON" } else { "Active: OFF" }}
                </button>
            </div>

            // ── Responsive breakpoints ───────────────────────────────
            <div class="flex flex-col gap-1">
                <span class="text-sm text-[#888]">{"Responsive breakpoints (resize window)"}</span>
                <div class="flex flex-col lg:flex-row gap-2">
                    <div class="bg-[#2563ff] p-3 rounded text-white">{"Box 1"}</div>
                    <div class="bg-[#10b981] p-3 rounded text-white">{"Box 2"}</div>
                    <div class="bg-[#9933ff] p-3 rounded text-white">{"Box 3"}</div>
                </div>
                <span class="sm:text-sm lg:text-lg text-[#aaa]">{"sm:text-sm lg:text-lg"}</span>
            </div>

            // ── Runtime classes (tw_dynamic) ─────────────────────────
            <div class="flex flex-col gap-1">
                <span class="text-sm text-[#888]">{"tw_dynamic() runtime"}</span>
                <div class={tw_dynamic("p-4 bg-[#2563ff] text-white rounded-lg")}>
                    {"Runtime-constructed class string"}
                </div>
            </div>
        </div>
    }
}

fn run() {
    #[cfg(not(target_family = "wasm"))]
    let gpui_app = application();

    #[cfg(target_family = "wasm")]
    let gpui_app = single_threaded_web();

    let launch = |cx: &mut App| {
        let bounds = Bounds::centered(None, size(px(700.), px(600.0)), cx);
        cx.open_window(
            WindowOptions {
                window_bounds: Some(WindowBounds::Windowed(bounds)),
                ..Default::default()
            },
            |window, cx| vgui::mount(window, cx, app),
        )
        .unwrap();
    };

    #[cfg(not(target_family = "wasm"))]
    gpui_app.run(launch);

    #[cfg(target_family = "wasm")]
    std::mem::forget(gpui_app.run_embedded(launch));
}

#[cfg(not(target_family = "wasm"))]
fn main() {
    run();
}

#[cfg(target_family = "wasm")]
#[wasm_bindgen::prelude::wasm_bindgen(start)]
pub fn start() {
    gpui_platform::web_init();
    vgui::intercept_keyboard_events();
    run();
}

Key Concepts

css! macro vs Tailwind classes

The first two sections produce the same visual result — a gradient flex container with two colored children — using two different mechanisms:

  • css! macro writes raw CSS declarations as the element’s inline style. Any valid CSS property works, including linear-gradient, box-shadow, and rgba() colors. Use it when you need a property that has no Tailwind equivalent or when you want pixel-precise control.
  • Tailwind classes (class="...") compile to the same CSS but via utility names: bg-gradient-to-br from-[#2563ff] to-[#6c757d] rounded-lg. Arbitrary values use the bracket syntax bg-[#2563ff].

Pseudo-state attributes (hover / active / focus)

Three buttons demonstrate the pseudo-state attributes. Each takes a css! block that is applied only while the element is in that state:

  • hover={css!{ background: #0044cc; }} — darkens the blue button on mouse-over.
  • active={css!{ background: #34d399; }} — brightens the green button while pressed.
  • focus={css!{ border: 2px solid #ffffff; }} — adds a white outline to the purple button when focused.

These map to the CSS :hover, :active, and :focus pseudo-classes without needing a separate stylesheet.

twc! for conditional classes

The dynamic-classes button uses twc! to compose a class string at render time from a base plus conditional Option<&str> arguments:

#![allow(unused)]
fn main() {
class={twc!(
    "p-3 rounded text-white transition-colors",
    active.get().then_some("bg-[#2563ff]"),
    (!active.get()).then_some("bg-[#6c757d]")
)}
}

When active is true the button gets the blue background; when false it gets the gray one. The label text also reacts via if active.get() { "Active: ON" } else { "Active: OFF" }. Clicking toggles the signal with set_active.update(cx, |v| *v = !*v).

Responsive breakpoints

The breakpoints section uses flex flex-col lg:flex-row so the three colored boxes stack vertically on narrow viewports and switch to a horizontal row at the lg breakpoint. The text below uses sm:text-sm lg:text-lg to change font size at the sm and lg breakpoints. Resize the window (or the iframe) to see the layout reflow.

tw_dynamic() for runtime class strings

tw_dynamic("p-4 bg-[#2563ff] text-white rounded-lg") accepts a plain &str built at runtime — useful when class fragments are assembled from variables or configuration. Unlike twc!, which takes compile-time-known fragments plus conditional Option<&str>s, tw_dynamic parses an arbitrary string at runtime and resolves it against the Tailwind engine.

Running

Native:

cargo run -p vgui-styling

Web (WASM):

cargo build --target wasm32-unknown-unknown -p vgui-styling --release
wasm-bindgen --target web --out-dir examples/styling/dist \
    --no-typescript target/wasm32-unknown-unknown/release/styling.wasm
python3 scripts/serve_plain.py 8080 examples/styling

Theming Example

Live Demo

Overview

This example demonstrates vgui’s CSS variable (custom property) system with runtime theming. It features:

  • --name: value custom property definitions inside theme!.
  • var(--name) references in css! for colors, lengths, and gradients.
  • Light/dark theme switching via set_theme() inside the render closure.
  • Reactive re-render: toggling a signal re-runs render, re-sets the theme, and rebuilds all styled elements.

Source Code

#![cfg_attr(target_family = "wasm", no_main)]

use gpui::{px, size, App, Bounds, WindowBounds, WindowOptions};
use vgui::prelude::*;

#[cfg(not(target_family = "wasm"))]
use gpui_platform::application;

#[cfg(target_family = "wasm")]
use gpui_platform::single_threaded_web;

/// Light theme built with the `theme!` macro.
fn light_theme() -> Theme {
    theme! {
        --bg: #f8f9fa;
        --surface: #ffffff;
        --text: #1a1a1a;
        --text-muted: #6c757d;
        --primary: #2563ff;
        --primary-hover: #1d4eff;
        --border: #dee2e6;
        --radius: 8px;
        --spacing: 16px;
    }
}

/// Dark theme — same variable names, different values.
fn dark_theme() -> Theme {
    theme! {
        --bg: #1a1a2f;
        --surface: #0f1623;
        --text: #e0e0e0;
        --text-muted: #8892b0;
        --primary: #4dabff;
        --primary-hover: #339aff;
        --border: #2d3748;
        --radius: 8px;
        --spacing: 16px;
    }
}

fn app() -> impl gpui::IntoElement {
    let (dark, set_dark) = create_signal(false);

    // Install the theme inside the render closure. Reading `dark.get()`
    // registers a reactive dependency, so toggling the signal re-runs render,
    // re-sets the theme, and rebuilds all styled elements.
    set_theme(if dark.get() {
        dark_theme()
    } else {
        light_theme()
    });

    view! {
        <div style={css! {
            display: flex;
            flex-direction: column;
            gap: var(--spacing);
            padding: var(--spacing);
            background: var(--bg);
            color: var(--text);
            width: 480px;
            height: 400px;
            font-size: 16px;
        }}>
            // Header row: title + toggle button
            <div style={css! {
                display: flex;
                justify-content: space-between;
                align-items: center;
            }}>
                <span style={css! {
                    font-size: 22px;
                    font-weight: bold;
                }}>
                    {"CSS Variables Theming"}
                </span>
                <button
                    style={css! {
                        padding: 8px 16px;
                        background: var(--primary);
                        color: #ffffff;
                        border-radius: var(--radius);
                        border-width: 0px;
                        cursor: pointer;
                        font-size: 14px;
                    }}
                    hover={css! {
                        background: var(--primary-hover);
                    }}
                    on:click={click(move |cx| set_dark.update(cx, |v| *v = !*v))}
                >
                    {if dark.get() { "🌙 Dark" } else { "☀ Light" }}
                </button>
            </div>

            // Card 1 — uses var() for background, border, radius
            <div style={css! {
                background: var(--surface);
                border-width: 1px;
                border-style: solid;
                border-color: var(--border);
                border-radius: var(--radius);
                padding: var(--spacing);
                display: flex;
                flex-direction: column;
                gap: 8px;
            }}>
                <span style={css! {
                    font-weight: bold;
                    font-size: 18px;
                }}>
                    {"Theme via var()"}
                </span>
                <span style={css! {
                    color: var(--text-muted);
                    font-size: 14px;
                }}>
                    {"Every color, spacing, and radius on this page reads from CSS variables. Toggle the theme to see them update reactively."}
                </span>
            </div>

            // Card 2 — gradient with var() color args
            <div style={css! {
                background: linear-gradient(135deg, var(--primary), var(--surface));
                border-radius: var(--radius);
                padding: var(--spacing);
                display: flex;
                flex-direction: column;
                gap: 8px;
            }}>
                <span style={css! {
                    font-weight: bold;
                    font-size: 18px;
                    color: #ffffff;
                }}>
                    {"Gradient with var()"}
                </span>
                <span style={css! {
                    color: #ffffff;
                    font-size: 14px;
                }}>
                    {"linear-gradient(135deg, var(--primary), var(--surface))"}
                </span>
            </div>

            // Card 3 — keyword + number vars
            <div style={css! {
                background: var(--surface);
                border-width: 1px;
                border-style: solid;
                border-color: var(--border);
                border-radius: var(--radius);
                padding: var(--spacing);
                display: flex;
                flex-direction: column;
                gap: 8px;
            }}>
                <span style={css! {
                    font-weight: bold;
                    font-size: 18px;
                }}>
                    {"Keyword & number vars"}
                </span>
                <span style={css! {
                    color: var(--text-muted);
                    font-size: 14px;
                    line-height: 1.5;
                }}>
                    {"--spacing is a length, --radius is a length, --primary is a color. All resolve at runtime from the thread-local theme."}
                </span>
            </div>
        </div>
    }
}

fn run() {
    #[cfg(not(target_family = "wasm"))]
    let gpui_app = application();

    #[cfg(target_family = "wasm")]
    let gpui_app = single_threaded_web();

    let launch = |cx: &mut App| {
        let bounds = Bounds::centered(None, size(px(480.), px(400.0)), cx);
        cx.open_window(
            WindowOptions {
                window_bounds: Some(WindowBounds::Windowed(bounds)),
                ..Default::default()
            },
            |window, cx| vgui::mount(window, cx, app),
        )
        .unwrap();
    };

    #[cfg(not(target_family = "wasm"))]
    gpui_app.run(launch);

    #[cfg(target_family = "wasm")]
    std::mem::forget(gpui_app.run_embedded(launch));
}

#[cfg(not(target_family = "wasm"))]
fn main() {
    run();
}

#[cfg(target_family = "wasm")]
#[wasm_bindgen::prelude::wasm_bindgen(start)]
pub fn start() {
    gpui_platform::web_init();
    vgui::intercept_keyboard_events();
    run();
}

Key Concepts

theme! macro

The theme! macro builds a Theme value from --name: value declarations, inferring the CssValue variant (color, length, number, keyword) from the literal syntax. Both light_theme() and dark_theme() use the same variable names with different values.

set_theme() inside the render closure

The key reactivity pattern: set_theme() is called at the top of app(), inside the render closure. Reading dark.get() registers a reactive dependency. When the toggle button flips the signal, render re-runs, set_theme() installs the new theme, and every var() reference resolves against the updated values.

var() in css!

Every var(--name) in a css! block emits a runtime lookup against the thread-local theme store. If the theme has the variable, that value wins. If not, the compile-time default (from a --name: value in the same css! block) or the var(--name, fallback) fallback is used.

var() in gradients

linear-gradient(135deg, var(--primary), var(--surface)) substitutes each color argument with a runtime __var_color() lookup. The angle stays a compile-time literal.

var() for lengths

--spacing: 16px and --radius: 8px are length variables. They’re used in gap: var(--spacing), padding: var(--spacing), and border-radius: var(--radius) — all resolve at runtime from the theme.

Running

Native:

cargo run -p vgui-theming

Web (WASM):

# Build the WASM binary
cargo build --target wasm32-unknown-unknown -p vgui-theming --release

# Generate JS bindings
wasm-bindgen --target web --out-dir examples/theming/dist \
    --no-typescript target/wasm32-unknown-unknown/release/theming.wasm

# Serve and open in a browser
python3 scripts/serve_plain.py 8080 examples/theming

Variants Example

Live Demo

Source Code

#![cfg_attr(target_family = "wasm", no_main)]

use gpui::{px, size, App, Bounds, WindowBounds, WindowOptions};
use vgui::prelude::*;
use vgui::for_each;

#[cfg(not(target_family = "wasm"))]
use gpui_platform::application;

#[cfg(target_family = "wasm")]
use gpui_platform::single_threaded_web;

// Define the `Button` variant system: a base style plus two dimensions
// (`variant` for color and `size` for padding/font-size). The macro generates
// `ButtonVariant`, `ButtonSize`, and `ButtonVariants` (a `Copy` struct that
// implements `ApplyStyle`).
variants! {
    Button {
        base => css! {
            border-radius: 6px;
            border-width: 0px;
            cursor: pointer;
            font-family: inherit;
        },

        variant {
            primary => css! { background: #2563ff; color: #fff; },
            secondary => css! { background: #6c757d; color: #fff; },
            danger => css! { background: #dc2626; color: #fff; },
            outline => css! { background: #1a1a2e; color: #2563ff; border: 2px solid #2563ff; },
        },

        size {
            sm => css! { padding: 4px 10px; font-size: 12px; },
            md => css! { padding: 8px 16px; font-size: 14px; },
            lg => css! { padding: 12px 22px; font-size: 16px; },
        },
    }
}

/// A reusable button component driven by the generated variant types.
pub struct Button {
    pub variant: ButtonVariant,
    pub size: ButtonSize,
    pub on_click: Box<dyn Fn(&mut gpui::App) + 'static>,
    pub children: Vec<gpui::AnyElement>,
}

impl gpui::IntoElement for Button {
    type Element = gpui::AnyElement;
    fn into_element(self) -> Self::Element {
        // Compose the selected options into a single `ButtonVariants` value;
        // `style={variants}` applies base + each dimension sequentially.
        let variants = ButtonVariants::default()
            .variant(self.variant)
            .size(self.size);
        let on_click = self.on_click;
        let children = self.children;
        view! {
            <button style={variants} on:click={click(move |cx| on_click(cx))}>
                {for_each(children, |c, _| c)}
            </button>
        }
        .into_any_element()
    }
}

fn btn(
    variant: ButtonVariant,
    size: ButtonSize,
    label: &str,
    on_click: impl Fn(&mut gpui::App) + 'static,
) -> Button {
    Button {
        variant,
        size,
        on_click: Box::new(on_click),
        children: vec![label.to_string().into_any_element()],
    }
}

fn label_name(variant: ButtonVariant, size: ButtonSize) -> String {
    let v = match variant {
        ButtonVariant::Primary => "Primary",
        ButtonVariant::Secondary => "Secondary",
        ButtonVariant::Danger => "Danger",
        ButtonVariant::Outline => "Outline",
    };
    let s = match size {
        ButtonSize::Sm => "sm",
        ButtonSize::Md => "md",
        ButtonSize::Lg => "lg",
    };
    format!("{v} · {s}")
}

fn row(size: ButtonSize, set: WriteSignal<u32>) -> impl gpui::IntoElement {
    let s1 = set.clone();
    let s2 = set.clone();
    let s3 = set.clone();
    let s4 = set.clone();
    view! {
        <div style={css! {
            display: flex;
            flex-direction: row;
            gap: 12px;
            align-items: center;
        }}>
            {btn(ButtonVariant::Primary, size, &label_name(ButtonVariant::Primary, size), move |cx| s1.update(cx, |n| *n += 1))}
            {btn(ButtonVariant::Secondary, size, &label_name(ButtonVariant::Secondary, size), move |cx| s2.update(cx, |n| *n += 1))}
            {btn(ButtonVariant::Danger, size, &label_name(ButtonVariant::Danger, size), move |cx| s3.update(cx, |n| *n += 1))}
            {btn(ButtonVariant::Outline, size, &label_name(ButtonVariant::Outline, size), move |cx| s4.update(cx, |n| *n += 1))}
        </div>
    }
}

fn app() -> impl gpui::IntoElement {
    let (clicks, set_clicks) = create_signal(0u32);
    view! {
        <div style={css! {
            display: flex;
            flex-direction: column;
            gap: 16px;
            padding: 24px;
            background: #1a1a2e;
        }}>
            <h2 style={css! {
                color: #fff;
                font-size: 18px;
                margin: 0;
            }}>
                {format!("Component variants — total clicks: {}", clicks.get())}
            </h2>
            {row(ButtonSize::Sm, set_clicks.clone())}
            {row(ButtonSize::Md, set_clicks.clone())}
            {row(ButtonSize::Lg, set_clicks.clone())}
        </div>
    }
}

fn run() {
    #[cfg(not(target_family = "wasm"))]
    let gpui_app = application();

    #[cfg(target_family = "wasm")]
    let gpui_app = single_threaded_web();

    let launch = |cx: &mut App| {
        let bounds = Bounds::centered(None, size(px(720.), px(420.0)), cx);
        cx.open_window(
            WindowOptions {
                window_bounds: Some(WindowBounds::Windowed(bounds)),
                ..Default::default()
            },
            |window, cx| vgui::mount(window, cx, app),
        )
        .unwrap();
    };

    #[cfg(not(target_family = "wasm"))]
    gpui_app.run(launch);

    #[cfg(target_family = "wasm")]
    std::mem::forget(gpui_app.run_embedded(launch));
}

#[cfg(not(target_family = "wasm"))]
fn main() {
    run();
}

#[cfg(target_family = "wasm")]
#[wasm_bindgen::prelude::wasm_bindgen(start)]
pub fn start() {
    gpui_platform::web_init();
    vgui::intercept_keyboard_events();
    run();
}

Key Concepts

variants! macro

The variants! macro declares a component variant system with a base style plus one or more dimensions. Each dimension is an enum whose variants carry css! styles. The macro generates:

  • ButtonVariant — enum for the variant dimension (primary, secondary, danger, outline).
  • ButtonSize — enum for the size dimension (sm, md, lg).
  • ButtonVariants — a Copy struct that implements ApplyStyle, composing the base style with one option per dimension.

Custom Button component

Button is a plain struct implementing gpui::IntoElement. Its into_element method composes a ButtonVariants value from the selected variant and size, then passes it as style={variants} — the ApplyStyle trait applies the base style followed by each dimension’s style sequentially.

btn() helper

The btn() function constructs a Button with a label string and click handler, returning the Button struct directly (which implements IntoElement).

for_each for children

for_each(children, |c, _| c) iterates the children vector inside the view! macro, rendering each child element.

Click counter signal

A create_signal(0u32) tracks total clicks across all buttons. Each button’s on_click closure increments the shared signal, and the header displays the running count reactively.

Running

Native:

cargo run -p vgui-variants

Web (WASM):

cargo build --target wasm32-unknown-unknown -p vgui-variants --release
wasm-bindgen --target web --out-dir examples/variants/dist \
    --no-typescript target/wasm32-unknown-unknown/release/variants.wasm
python3 scripts/serve_plain.py 8080 examples/variants

Inputs Example

Live Demo

Overview

The inputs example is a comprehensive tour of every form control vgui supports. It demonstrates:

  • Text, password, number, and date inputs with on:input handlers.
  • Checkbox and radio inputs with on:change handlers.
  • Range slider with on:change returning an f64.
  • File picker with on:change returning Vec<PathBuf>.
  • <select> with grouped options via the groups prop.
  • Multiple select with comma-separated values.
  • <select> with a custom child closure for per-option rendering.
  • tabindex for focus ordering.
  • Label association via the for attribute and wrapping <label> elements.

Source Code

#![cfg_attr(target_family = "wasm", no_main)]

use gpui::{px, size, App, Bounds, WindowBounds, WindowOptions};
use vgui::prelude::*;

#[cfg(not(target_family = "wasm"))]
use gpui_platform::application;

#[cfg(target_family = "wasm")]
use gpui_platform::single_threaded_web;

fn app() -> impl gpui::IntoElement {
    let (text, set_text) = create_signal(String::new());
    let (password, set_password) = create_signal(String::new());
    let (checked, set_checked) = create_signal(false);
    let (radio_val, set_radio) = create_signal(0i32);
    let (slider, set_slider) = create_signal(50.0f64);
    let (sel_val, set_sel_val) = create_signal("a".to_string());
    let (number_val, set_number) = create_signal(String::new());
    let (date_val, set_date) = create_signal(String::new());
    let (group_sel, set_group_sel) = create_signal("apple".to_string());
    let (multi_sel, set_multi_sel) = create_signal("1,3".to_string());
    let (custom_sel, set_custom_sel) = create_signal("1".to_string());
    let sr0 = set_radio.clone();
    let sr0b = set_radio.clone();
    let scb = set_checked.clone();
    let sr1 = set_radio.clone();
    let sr2 = set_radio.clone();

    view! {
        <div class="flex flex-col gap-4 p-6 bg-[#1a1a2e] w-[600px] h-[700px] text-white overflow-y-auto">
            <span class="text-lg font-bold">{"vgui <input> demo"}</span>

            // Text input with live mirror
            <div class="flex flex-col gap-1">
                <span class="text-sm text-[#888]">{"Text (on:input)"}</span>
                <input
                    type="text"
                    placeholder="Type here..."
                    on:input={move |v: &str, cx: &mut App| set_text.set(cx, v.to_string())}
                    tabindex={0}
                />
                <span class="text-sm text-[#0f0]">{format!("echo: \"{}\"", text.get())}</span>
            </div>

            // Password
            <div class="flex flex-col gap-1">
                <span class="text-sm text-[#888]">{"Password"}</span>
                <input
                    type="password"
                    placeholder="secret"
                    on:input={move |v: &str, cx: &mut App| set_password.set(cx, v.to_string())}
                    tabindex={0}
                />
                <span class="text-sm text-[#0f0]">{format!("len: {} chars", password.get().chars().count())}</span>
            </div>

            // Labeled text input (for attribute)
            <div class="flex flex-col gap-1">
                <label for="username" class="text-sm text-[#888]">{"Username"}</label>
                <input type="text" id="username" placeholder="Enter username" tabindex={0} />
            </div>

            // Wrapping label with text input
            <label class="flex flex-col gap-1">
                <span class="text-sm text-[#888]">{"Wrapped input"}</span>
                <input type="text" placeholder="Click label to focus" tabindex={0} />
            </label>

            // Checkbox
            <div class="flex flex-row gap-2 items-center">
                <input
                    type="checkbox"
                    checked={checked.get()}
                    on:change={move |v: bool, cx: &mut App| set_checked.set(cx, v)}
                    tabindex={-1}
                />
                <span class="text-sm">{format!("checkbox: {}", if checked.get() { "on" } else { "off" })}</span>
            </div>

            // Radio group
            <div class="flex flex-col gap-1">
                <span class="text-sm text-[#888]">{"Radio group"}</span>
                <div class="flex flex-row gap-4 items-center">
                    <div class="flex flex-row gap-1 items-center">
                        <input type="radio" checked={radio_val.get() == 0} on:change={move |_v: bool, cx: &mut App| sr0.set(cx, 0)} />
                        <span class="text-sm">{"A"}</span>
                    </div>
                    <div class="flex flex-row gap-1 items-center">
                        <input type="radio" checked={radio_val.get() == 1} on:change={move |_v: bool, cx: &mut App| sr1.set(cx, 1)} />
                        <span class="text-sm">{"B"}</span>
                    </div>
                    <div class="flex flex-row gap-1 items-center">
                        <input type="radio" checked={radio_val.get() == 2} on:change={move |_v: bool, cx: &mut App| sr2.set(cx, 2)} />
                        <span class="text-sm">{"C"}</span>
                    </div>
                </div>
                <span class="text-sm text-[#0f0]">{format!("selected: {}", radio_val.get())}</span>
            </div>

            // Range slider
            <div class="flex flex-col gap-1">
                <span class="text-sm text-[#888]">{"Range slider"}</span>
                <input
                    type="range"
                    min={0.0f64}
                    max={100.0f64}
                    step={1.0f64}
                    value={slider.get()}
                    on:change={move |v: f64, cx: &mut App| set_slider.set(cx, v)}
                    tabindex={1}
                />
                <span class="text-sm text-[#0f0]">{format!("value: {:.1}", slider.get())}</span>
            </div>

            // Number input
            <div class="flex flex-col gap-1">
                <span class="text-sm text-[#888]">{"Number (min 0, max 100)"}</span>
                <input
                    type="number"
                    min={0.0f64}
                    max={100.0f64}
                    placeholder="42"
                    on:input={move |v: &str, cx: &mut App| set_number.set(cx, v.to_string())}
                    tabindex={0}
                />
                <span class="text-sm text-[#0f0]">{format!("number: \"{}\"", number_val.get())}</span>
            </div>

            // Date input (text-entry v1)
            <div class="flex flex-col gap-1">
                <span class="text-sm text-[#888]">{"Date (YYYY-MM-DD)"}</span>
                <input
                    type="date"
                    placeholder="2026-01-15"
                    on:input={move |v: &str, cx: &mut App| set_date.set(cx, v.to_string())}
                    tabindex={0}
                />
                <span class="text-sm text-[#0f0]">{format!("date: \"{}\"", date_val.get())}</span>
            </div>

            // File input
            <div class="flex flex-col gap-1">
                <span class="text-sm text-[#888]">{"File picker"}</span>
                <input
                    type="file"
                    value="Choose file..."
                    on:change={move |paths: Vec<std::path::PathBuf>, _cx: &mut App| {
                        if let Some(p) = paths.first() {
                            eprintln!("file selected: {:?}", p);
                        }
                    }}
                />
            </div>

            // Submit button
            <input type="submit" value="Submit" on:click={click(move |_cx| eprintln!("submit clicked"))} />

            // Hidden input (renders nothing)
            <input type="hidden" value="invisible" />

            // Wrapping label with checkbox (click label to focus checkbox)
            <label class="flex flex-row gap-2 items-center">
                <input type="checkbox" checked={checked.get()} on:change={move |v: bool, cx: &mut App| scb.set(cx, v)} />
                <span class="text-sm">{"Wrapped checkbox"}</span>
            </label>

            // Wrapping label with radio
            <label class="flex flex-row gap-2 items-center">
                <input type="radio" checked={radio_val.get() == 0} on:change={move |_v: bool, cx: &mut App| sr0b.set(cx, 0)} />
                <span class="text-sm">{"Wrapped radio A"}</span>
            </label>

            // Wrapping label with select
            <label class="flex flex-col gap-1">
                <span class="text-sm text-[#888]">{"Wrapped select"}</span>
                <select options={vec![("a".to_string(), "Apple".to_string()), ("b".to_string(), "Banana".to_string())]} value={sel_val.get()} on:change={move |v: &str, cx: &mut App| set_sel_val.set(cx, v.to_string())} />
            </label>

            <span class="text-sm text-[#0f0]">{format!("select: {}", sel_val.get())}</span>

            // Wrapping label with file input
            <label class="flex flex-col gap-1">
                <span class="text-sm text-[#888]">{"Wrapped file input"}</span>
                <input type="file" value="Choose file..." on:change={move |paths: Vec<std::path::PathBuf>, _cx: &mut App| { if let Some(p) = paths.first() { eprintln!("file: {:?}", p); } }} />
            </label>

            <hr />

            // ── Select with grouped options ──────────────────────────
            <div class="flex flex-col gap-1">
                <span class="text-sm text-[#888]">{"Select with groups"}</span>
                <select
                    groups={vec![
                        ("Fruits".to_string(), vec![
                            ("apple".to_string(), "Apple".to_string()),
                            ("banana".to_string(), "Banana".to_string()),
                        ]),
                        ("Vegetables".to_string(), vec![
                            ("carrot".to_string(), "Carrot".to_string()),
                            ("daikon".to_string(), "Daikon".to_string()),
                        ]),
                    ]}
                    value={group_sel.get()}
                    on:change={move |v: &str, cx: &mut App| set_group_sel.set(cx, v.to_string())}
                />
                <span class="text-sm text-[#0f0]">{format!("grouped select: {}", group_sel.get())}</span>
            </div>

            // ── Multiple select ──────────────────────────────────────
            <div class="flex flex-col gap-1">
                <span class="text-sm text-[#888]">{"Multiple select (comma-separated values)"}</span>
                <select
                    options={vec![
                        ("1".to_string(), "One".to_string()),
                        ("2".to_string(), "Two".to_string()),
                        ("3".to_string(), "Three".to_string()),
                    ]}
                    value={multi_sel.get()}
                    multiple={true}
                    on:change={move |v: &str, cx: &mut App| set_multi_sel.set(cx, v.to_string())}
                />
                <span class="text-sm text-[#0f0]">{format!("multi select: {}", multi_sel.get())}</span>
            </div>

            // ── Select with custom child closure ─────────────────────
            <div class="flex flex-col gap-1">
                <span class="text-sm text-[#888]">{"Select with custom option rendering"}</span>
                <select
                    options={vec![
                        ("1".to_string(), "One".to_string()),
                        ("2".to_string(), "Two".to_string()),
                        ("3".to_string(), "Three".to_string()),
                    ]}
                    value={custom_sel.get()}
                    on:change={move |v: &str, cx: &mut App| set_custom_sel.set(cx, v.to_string())}
                >
                    {move |value: &str, label: &str| view! {
                        <div class="flex items-center gap-2">
                            <span class="text-[#0f0]">{value.to_string()}</span>
                            <span>{label.to_string()}</span>
                        </div>
                    }}
                </select>
                <span class="text-sm text-[#0f0]">{format!("custom select: {}", custom_sel.get())}</span>
            </div>
        </div>
    }
}

fn run() {
    #[cfg(not(target_family = "wasm"))]
    let gpui_app = application();

    #[cfg(target_family = "wasm")]
    let gpui_app = single_threaded_web();

    let launch = |cx: &mut App| {
        let bounds = Bounds::centered(None, size(px(600.), px(700.0)), cx);
        cx.open_window(
            WindowOptions {
                window_bounds: Some(WindowBounds::Windowed(bounds)),
                ..Default::default()
            },
            |window, cx| vgui::mount(window, cx, app),
        )
        .unwrap();
    };

    #[cfg(not(target_family = "wasm"))]
    gpui_app.run(launch);

    #[cfg(target_family = "wasm")]
    std::mem::forget(gpui_app.run_embedded(launch));
}

#[cfg(not(target_family = "wasm"))]
fn main() {
    run();
}

#[cfg(target_family = "wasm")]
#[wasm_bindgen::prelude::wasm_bindgen(start)]
pub fn start() {
    gpui_platform::web_init();
    vgui::intercept_keyboard_events();
    run();
}

Key Concepts

Event handler signatures per input type

Each input type delivers a different value type to its event handler. The closure signature must match the value type the element emits:

Input typeEventHandler value typeExample
text / password / number / dateon:input&strmove |v: &str, cx: &mut App| set_text.set(cx, v.to_string())
checkbox / radioon:changeboolmove |v: bool, cx: &mut App| set_checked.set(cx, v)
rangeon:changef64move |v: f64, cx: &mut App| set_slider.set(cx, v)
fileon:changeVec<PathBuf>move |paths: Vec<PathBuf>, _cx: &mut App| { ... }
selecton:change&strmove |v: &str, cx: &mut App| set_sel.set(cx, v.to_string())
submiton:click— (use click)click(move |_cx| { ... })

Text-entry inputs (text, password, number, date) fire on:input on every keystroke with the current string value. Toggle inputs (checkbox, radio) and range fire on:change with the new typed value. The file input fires on:change with the full list of selected paths.

tabindex

The tabindex attribute controls focus order. tabindex={0} places the element in the natural tab order. tabindex={1} (the range slider) moves it ahead of default-order elements. tabindex={-1} (the standalone checkbox) removes it from sequential tab navigation while keeping it focusable programmatically.

Label association

Two patterns associate a <label> with its control:

  1. for attribute<label for="username"> references an input by its id="username". Clicking the label focuses the input.
  2. Wrapping label — The control is nested inside <label>...</label>. The example wraps text inputs, checkboxes, radios, selects, and file inputs this way. Clicking anywhere on the label text focuses or toggles the wrapped control.

Select with grouped options

The groups prop accepts a Vec<(String, Vec<(String, String)>)> — a list of (group_label, options) pairs. Each group renders as an <optgroup> with its label and nested options. The value prop selects the active option and on:change fires with the chosen option’s value string.

Multiple select

Setting multiple={true} enables multi-selection. The value prop holds the currently selected values as a comma-separated string (e.g. "1,3"), and on:change delivers the updated comma-separated string.

Select with custom child closure

A <select> may take a child closure {move |value: &str, label: &str| view! { ... }} that renders each option. The closure receives the option’s value and label and returns an element, allowing rich per-option layouts (icons, badges, multi-line content) instead of plain text.

Running

Native:

cargo run -p vgui-inputs

Web (WASM):

cargo build --target wasm32-unknown-unknown -p vgui-inputs --release
wasm-bindgen --target web --out-dir examples/inputs/dist \
    --no-typescript target/wasm32-unknown-unknown/release/inputs.wasm
python3 scripts/serve_plain.py 8080 examples/inputs

HTML Elements Example

Live Demo

Overview

The HTML elements example renders a broad swath of the HTML tag surface that vgui supports, side by side on one page. It demonstrates:

  • h1h6 headings.
  • Text formatting: strong, em, u, s, mark, code, small.
  • Lists: ul, ol, dl (with dt/dd).
  • Semantic tags: header, nav, main, section, article, aside, footer.
  • Links via <a> with on:click.
  • CSS properties applied through the css! macro.
  • Tailwind utility classes for text overflow, font families, leading, and decoration.
  • progress and meter gauges.
  • textarea with on:input.
  • select with options and on:change.
  • details/summary toggling.
  • dialog with open and on:close.
  • <img> with on:load / on:error event callbacks.

Source Code

#![cfg_attr(target_family = "wasm", no_main)]

use gpui::{px, size, App, Bounds, RenderImage, WindowBounds, WindowOptions};
use vgui::prelude::*;

#[cfg(not(target_family = "wasm"))]
use gpui_platform::application;

#[cfg(target_family = "wasm")]
use gpui_platform::single_threaded_web;

fn app() -> impl gpui::IntoElement {
    let (open, set_open) = create_signal(false);
    let (text, set_text) = create_signal("Hello".to_string());
    let (show_dialog, set_show_dialog) = create_signal(false);
    let (sel_val, set_sel_val) = create_signal("1".to_string());
    let dismiss_dialog = set_show_dialog.clone();
    let close_dialog_btn = set_show_dialog.clone();
    let (img_loaded, set_img_loaded) = create_signal(false);
    let (img_error, set_img_error) = create_signal(false);
    // A tiny 4×4 solid-color image used as a Render source — `on:load` fires
    // on first paint because the data is immediately available.
    let solid_image = std::sync::Arc::new(RenderImage::new(vec![
        image::Frame::new(image::RgbaImage::from_raw(4, 4, [80, 120, 200, 255].repeat(16)).unwrap()),
    ]));

    view! {
        <div class="flex flex-col gap-2 p-4 bg-[#1a1a2e] w-[600px] h-[700px] text-white overflow-y-auto">
            <h1>{"Heading 1"}</h1>
            <h2>{"Heading 2"}</h2>
            <h3>{"Heading 3"}</h3>
            <h4>{"Heading 4"}</h4>
            <h5>{"Heading 5"}</h5>
            <h6>{"Heading 6"}</h6>

            <hr />

            <p>{"Normal paragraph"}</p>
            <strong>{"Bold text"}</strong>
            <em>{"Italic text"}</em>
            <u>{"Underlined"}</u>
            <s>{"Strikethrough"}</s>
            <mark>{"Highlighted"}</mark>
            <code>{"monospace"}</code>
            <small>{"small text"}</small>

            <hr />

            <ul>
                <li>{"Item 1"}</li>
                <li>{"Item 2"}</li>
            </ul>
            <ol>
                <li>{"First"}</li>
                <li>{"Second"}</li>
            </ol>
            <dl>
                <dt>{"Term"}</dt>
                <dd>{"Definition"}</dd>
            </dl>

            <hr />

            <header>{"Header"}</header>
            <nav>{"Nav"}</nav>
            <main>{"Main content"}</main>
            <section>{"Section"}</section>
            <article>{"Article"}</article>
            <aside>{"Aside"}</aside>
            <footer>{"Footer"}</footer>

            <hr />

            <a on:click={click(move |_cx| {})}>{"Click link"}</a>

            <hr />

            <div style={css! {
                font-family: monospace;
                text-overflow: ellipsis;
                text-decoration-color: #ff0000;
                text-decoration-thickness: 2px;
                text-decoration-style: wavy;
                text-background: #ffff00;
                scrollbar-width: thin;
                background: linear-gradient(90deg, #ff0000, #0000ff);
                white-space: nowrap;
                overflow: hidden;
            }}>
                <span>{"CSS properties test"}</span>
            </div>

            <hr />

            <div class="truncate font-mono leading-none">{"Truncated monospace text with leading-none"}</div>
            <div class="text-ellipsis font-serif leading-loose">{"Ellipsis serif with leading-loose"}</div>
            <div class="underline decoration-wavy decoration-2">{"Wavy underline thickness 2"}</div>

            <hr />

            <progress value={0.5f64} max={1.0f64} />

            <meter value={0.7f64} max={1.0f64} />

            <hr />

            <textarea
                placeholder="Enter text"
                value={text.get()}
                on:input={move |v: &str, cx: &mut App| set_text.set(cx, v.to_string())}
            />

            <hr />

            <select
                options={vec![("1".to_string(), "One".to_string()), ("2".to_string(), "Two".to_string())]}
                value={sel_val.get()}
                on:change={move |v: &str, cx: &mut App| set_sel_val.set(cx, v.to_string())}
            />
            <hr />

            <details open={open.get()}>
                <summary on:click={click(move |cx| set_open.update(cx, |v| *v = !*v))}>
                    {"Click to toggle"}
                </summary>
                <div>{"Hidden content"}</div>
            </details>

            <hr />

            <button on:click={click(move |cx| set_show_dialog.set(cx, true))}>
                {"Open Dialog"}
            </button>
            <dialog open={show_dialog.get()} on:close={move |cx| dismiss_dialog.set(cx, false)}>
                <div class="bg-white p-4 rounded text-black">
                    <p>{"Dialog content — click outside or press Escape to close."}</p>
                    <button on:click={click(move |cx| close_dialog_btn.set(cx, false))}>
                        {"Close"}
                    </button>
                </div>
            </dialog>

            <div on:modifiers_changed={move |_e, _w, _cx| {}} on:any_mouse_down={move |_e, _w, _cx| {}}>
                {"Events test"}
            </div>

            <hr />

            <div class="flex flex-col gap-1">
                <span>{"<img> with on:load / on:error"}</span>
                <div class="flex flex-row gap-2 items-center">
                    <img src={solid_image.clone()} object_fit="contain" class="w-4 h-4"
                        on:load={move |cx: &mut App| set_img_loaded.set(cx, true)} />
                    <span>{if img_loaded.get() { "Loaded ✓" } else { "Loading…" }}</span>
                </div>
                <div class="flex flex-row gap-2 items-center">
                    <img src={"nonexistent.png"} class="w-4 h-4"
                        on:error={move |cx: &mut App| set_img_error.set(cx, true)} />
                    <span>{if img_error.get() { "Error ✓ (expected)" } else { "Loading…" }}</span>
                </div>
            </div>

            <hr />

            <table class="w-full">
                <thead>
                    <tr class="bg-[#333]">
                        <th class="p-2 text-white">{"Name"}</th>
                        <th class="p-2 text-white">{"Age"}</th>
                        <th class="p-2 text-white">{"City"}</th>
                    </tr>
                </thead>
                <tbody>
                    <tr>
                        <td class="p-2">{"Alice"}</td>
                        <td class="p-2">{"30"}</td>
                        <td class="p-2">{"Beijing"}</td>
                    </tr>
                    <tr>
                        <td class="p-2" colspan={2u32}>{"Bob (spanned 2 cols)"}</td>
                        <td class="p-2">{"Shanghai"}</td>
                    </tr>
                </tbody>
            </table>
        </div>
    }
}

fn run() {
    #[cfg(not(target_family = "wasm"))]
    let gpui_app = application();

    #[cfg(target_family = "wasm")]
    let gpui_app = single_threaded_web();

    let launch = |cx: &mut App| {
        let bounds = Bounds::centered(None, size(px(600.), px(700.0)), cx);
        cx.open_window(
            WindowOptions {
                window_bounds: Some(WindowBounds::Windowed(bounds)),
                ..Default::default()
            },
            |window, cx| vgui::mount(window, cx, app),
        )
        .unwrap();
    };

    #[cfg(not(target_family = "wasm"))]
    gpui_app.run(launch);

    #[cfg(target_family = "wasm")]
    std::mem::forget(gpui_app.run_embedded(launch));
}

#[cfg(not(target_family = "wasm"))]
fn main() {
    run();
}

#[cfg(target_family = "wasm")]
#[wasm_bindgen::prelude::wasm_bindgen(start)]
pub fn start() {
    gpui_platform::web_init();
    vgui::intercept_keyboard_events();
    run();
}

Key Concepts

HTML tag coverage

vgui maps a large subset of HTML elements directly to gpui primitives. This example exercises them all in one scrollable column:

  • Headings<h1> through <h6> render at decreasing sizes.
  • Text formatting<strong>, <em>, <u>, <s>, <mark>, <code>, and <small> apply their conventional visual styling inline.
  • Lists<ul>/<li> for bulleted lists, <ol>/<li> for numbered lists, and <dl> with <dt>/<dd> for description lists.
  • Semantic structure<header>, <nav>, <main>, <section>, <article>, <aside>, and <footer> render as block containers.
  • Links<a> takes an on:click handler; the example wires it to a no-op closure demonstrating the pattern.
  • Tables<table> with <thead>/<tbody>/<tr>/<th>/<td>. The colspan={2u32} attribute on a <td> spans it across two columns.

CSS properties via the css! macro

The style={css! { ... }} attribute applies raw CSS declarations to an element. The example sets font-family, text-overflow, text-decoration-*, gradient background, white-space, and overflow — properties that are awkward or impossible to express as Tailwind utilities. The css! macro emits a typed StyleRefinement that merges with any class= utilities on the same element.

Tailwind utility classes

Three <div> elements demonstrate utility-class equivalents of the CSS above: truncate (overflow-hidden + text-ellipsis + whitespace-nowrap), font-mono/font-serif for font families, leading-none/leading-loose for line height, and underline decoration-wavy decoration-2 for text decoration. Arbitrary values and standard utilities compose freely in a single class= string.

Interactive elements

  • <details>/<summary> — The open prop is bound to a signal. Clicking the <summary> toggles the signal via on:click, which reactively opens or closes the hidden content block.
  • <dialog> — The open prop is signal-driven. The on:close handler fires when the dialog is dismissed (Escape key or click-outside), resetting the signal to false. A manual close button sets the same signal directly.

Image events (on:load / on:error)

<img> supports two <img>-only events beyond the standard on:click etc.:

  • on:load — fires when the image source finishes loading. The handler takes Fn(&mut App) (no event payload).
  • on:error — fires when the image source fails to load. Same signature.

The example demonstrates both:

  • A RenderImage source (a tiny 4×4 solid-color rectangle built with the image crate) fires on:load on first paint because the data is immediately available. The callback sets a signal that reactively updates the status text from “Loading…” to “Loaded ✓”.
  • A non-existent "nonexistent.png" resource triggers on:error, updating the status to “Error ✓ (expected)”.

No click() wrapper is needed — pass the closure directly, like on:close on <dialog>. When only one of the two callbacks is specified, the other defaults to a no-op.

Running

Native:

cargo run -p vgui-elements

Web (WASM):

cargo build --target wasm32-unknown-unknown -p vgui-elements --release
wasm-bindgen --target web --out-dir examples/elements/dist \
    --no-typescript target/wasm32-unknown-unknown/release/elements.wasm
python3 scripts/serve_plain.py 8080 examples/elements

Forms Example

Live Demo

Overview

The forms example demonstrates end-to-end form handling in vgui. It shows:

  • <form> with on:submit and on:reset handlers.
  • Text, email, and number <input> types with on:input value binding.
  • <select> with an options vector of (value, label) pairs.
  • Checkbox <input> with a boolean on:change handler.
  • Submit and reset <input> buttons.
  • <Show> for conditional display of the submitted data.
  • Enter-to-submit: pressing Enter in a text input inside <form> fires on:submit.

Source Code

#![cfg_attr(target_family = "wasm", no_main)]

use gpui::{px, size, App, Bounds, WindowBounds, WindowOptions};
use vgui::prelude::*;

#[cfg(not(target_family = "wasm"))]
use gpui_platform::application;

#[cfg(target_family = "wasm")]
use gpui_platform::single_threaded_web;

#[derive(Clone, PartialEq)]
struct FormData {
    name: String,
    email: String,
    age: String,
    country: String,
    subscribe: bool,
}

fn app() -> impl gpui::IntoElement {
    let (name, set_name) = create_signal(String::new());
    let (email, set_email) = create_signal(String::new());
    let (age, set_age) = create_signal(String::new());
    let (country, set_country) = create_signal("cn".to_string());
    let (subscribe, set_subscribe) = create_signal(false);
    let (submitted, set_submitted) = create_signal(Option::<FormData>::None);

    // Clones for the on:submit closure (the view! attributes below
    // consume separate clones).
    let s_name = name.clone();
    let s_email = email.clone();
    let s_age = age.clone();
    let s_country = country.clone();
    let s_subscribe = subscribe.clone();
    let r_name = set_name.clone();
    let r_email = set_email.clone();
    let r_age = set_age.clone();
    let r_country = set_country.clone();
    let r_subscribe = set_subscribe.clone();
    let r_submitted = set_submitted.clone();
    view! {
        <div class="flex flex-col gap-4 p-6 bg-[#1a1a2e] text-white" style={css!{ width: 500px; height: 600px; overflow-y: auto; }}>
            <h2 class="text-lg font-bold">{"Form Example"}</h2>
            <form
                on:submit={move |cx: &mut App| {
                    set_submitted.set(cx, Some(FormData {
                        name: s_name.get(),
                        email: s_email.get(),
                        age: s_age.get(),
                        country: s_country.get(),
                        subscribe: s_subscribe.get(),
                    }));
                }}
                on:reset={move |cx: &mut App| {
                    r_name.set(cx, String::new());
                    r_email.set(cx, String::new());
                    r_age.set(cx, String::new());
                    r_country.set(cx, "cn".to_string());
                    r_subscribe.set(cx, false);
                    r_submitted.set(cx, None);
                }}
            >
                <div class="flex flex-col gap-3">
                    <label class="flex flex-col gap-1">
                        <span class="text-sm text-[#888]">{"Name"}</span>
                        <input
                            type="text"
                            placeholder="Enter your name"
                            value={name.get()}
                            on:input={move |v: &str, cx: &mut App| set_name.set(cx, v.to_string())}
                        />
                    </label>

                    <label class="flex flex-col gap-1">
                        <span class="text-sm text-[#888]">{"Email"}</span>
                        <input
                            type="email"
                            placeholder="you@example.com"
                            value={email.get()}
                            on:input={move |v: &str, cx: &mut App| set_email.set(cx, v.to_string())}
                        />
                    </label>

                    <label class="flex flex-col gap-1">
                        <span class="text-sm text-[#888]">{"Age"}</span>
                        <input
                            type="number"
                            min={0.0f64}
                            max={150.0f64}
                            placeholder="30"
                            value={age.get()}
                            on:input={move |v: &str, cx: &mut App| set_age.set(cx, v.to_string())}
                        />
                    </label>

                    <div class="flex flex-col gap-1">
                        <span class="text-sm text-[#888]">{"Country"}</span>
                        <select
                            options={vec![
                                ("cn".to_string(), "China".to_string()),
                                ("us".to_string(), "United States".to_string()),
                                ("jp".to_string(), "Japan".to_string()),
                            ]}
                            value={country.get()}
                            on:change={move |v: &str, cx: &mut App| set_country.set(cx, v.to_string())}
                        />
                    </div>

                    <label class="flex flex-row gap-2 items-center">
                        <input
                            type="checkbox"
                            checked={subscribe.get()}
                            on:change={move |v: bool, cx: &mut App| set_subscribe.set(cx, v)}
                        />
                        <span class="text-sm">{"Subscribe to newsletter"}</span>
                    </label>

                    <div class="flex flex-row gap-3">
                        <input type="submit" value="Submit" class="px-4 py-2 bg-[#2563ff] text-white rounded cursor-pointer" />
                        <input type="reset" value="Reset" class="px-4 py-2 bg-[#6c757d] text-white rounded cursor-pointer" />
                    </div>
                </div>
            </form>

            <Show when={submitted.get().is_some()}>
                <div class="bg-[#2d2d44] p-4 rounded-lg">
                    <span class="text-sm font-bold">{"Submitted data:"}</span>
                    {if let Some(data) = submitted.get() {
                        view! {
                            <div class="text-sm text-[#0f0] mt-2">
                                <div>{format!("Name: {}", data.name)}</div>
                                <div>{format!("Email: {}", data.email)}</div>
                                <div>{format!("Age: {}", data.age)}</div>
                                <div>{format!("Country: {}", data.country)}</div>
                                <div>{format!("Subscribed: {}", data.subscribe)}</div>
                            </div>
                        }.into_any_element()
                    } else {
                        gpui::div().into_any_element()
                    }}
                </div>
            </Show>
        </div>
    }
}

fn run() {
    #[cfg(not(target_family = "wasm"))]
    let gpui_app = application();

    #[cfg(target_family = "wasm")]
    let gpui_app = single_threaded_web();

    let launch = |cx: &mut App| {
        let bounds = Bounds::centered(None, size(px(500.), px(600.0)), cx);
        cx.open_window(
            WindowOptions {
                window_bounds: Some(WindowBounds::Windowed(bounds)),
                ..Default::default()
            },
            |window, cx| vgui::mount(window, cx, app),
        )
        .unwrap();
    };

    #[cfg(not(target_family = "wasm"))]
    gpui_app.run(launch);

    #[cfg(target_family = "wasm")]
    std::mem::forget(gpui_app.run_embedded(launch));
}

#[cfg(not(target_family = "wasm"))]
fn main() {
    run();
}

#[cfg(target_family = "wasm")]
#[wasm_bindgen::prelude::wasm_bindgen(start)]
pub fn start() {
    gpui_platform::web_init();
    vgui::intercept_keyboard_events();
    run();
}

Key Concepts

on:submit and on:reset handlers

The <form> element carries two event handlers:

  • on:submit fires when the form is submitted — either by clicking the <input type="submit"> button or by pressing Enter inside a text input. The handler reads every field signal and packs them into a FormData struct, storing it in the submitted signal.
  • on:reset fires when the <input type="reset"> button is clicked. It resets every field signal back to its default and clears submitted to None.

Both handlers take move |cx: &mut App| { ... }. Because view! attributes consume the closures, the read and write signals are cloned into s_* and r_* bindings before the view! macro so each closure captures its own owned clone.

Enter-to-submit

Text inputs (type="text", type="email", type="number") placed inside a <form> automatically submit the form when the user presses Enter. No extra keydown handler is needed — the platform form semantics handle it. The on:submit handler then collects the current signal values into FormData.

<Show> with is_some()

The submitted-data card is wrapped in <Show when={submitted.get().is_some()}>. When submitted is None (before the first submit, or after a reset) the card is not rendered at all. Inside the <Show>, an if let Some(data) = submitted.get() block extracts the struct and renders each field; the else branch returns gpui::div().into_any_element() as a type-compatible fallback (never reached because <Show> already gated on is_some()).

FormData with Clone + PartialEq

The FormData struct derives Clone and PartialEq. Clone is required so the struct can be moved into signals and read back; PartialEq lets the reactive system skip re-renders when the submitted data is unchanged. The struct holds plain String and bool fields — one per form field — making it a faithful snapshot of the form state at submit time.

Input types and value binding

Each input binds its value to a signal read (value={name.get()}) and updates the signal on every keystroke via on:input={move |v: &str, cx: &mut App| set_name.set(cx, v.to_string())}. The <select> uses the options prop with a Vec<(String, String)> of (value, label) pairs and on:change for selection changes. The checkbox uses checked={subscribe.get()} with a boolean on:change={move |v: bool, cx: &mut App| set_subscribe.set(cx, v)}.

Running

Native:

cargo run -p vgui-forms

Web (WASM):

cargo build --target wasm32-unknown-unknown -p vgui-forms --release
wasm-bindgen --target web --out-dir examples/forms/dist \
    --no-typescript target/wasm32-unknown-unknown/release/forms.wasm
python3 scripts/serve_plain.py 8080 examples/forms

Context / Provider Example

Live Demo

Overview

This example demonstrates vgui’s Context / Provider pattern — the SolidJS-equivalent of createContext / useContext / <Provider> for dependency injection down the element tree.

The example is split across three files — theme.rs (the context type, marker, and a leaf consumer), panel.rs (a sub-component that both consumes and provides), and main.rs (the root provider and entry point) — to show that a Context<T> marker crosses file/module boundaries for free.

It features:

  • Context::new() — a zero-sized, const-constructable typed marker stored in a plain static, keyed by TypeId.
  • <Provider context={...} value={...}> — pushes a value onto a thread-local stack before evaluating children and pops it after, so descendants observe the value during construction.
  • use_context / use_context_or — read the nearest ancestor provider value (or a default fallback).
  • Nearest-ancestor resolution: a nested <Provider> shadows an outer one for its subtree; after the inner provider closes, the outer value is visible again.

Source Code

theme.rs — the context type, marker, and leaf consumer:

#![allow(unused)]
fn main() {
use vgui::prelude::*;

/// A theme mode propagated through the element tree via `<Provider>`.
#[derive(Clone, PartialEq)]
pub enum Mode {
    Light,
    Dark,
}

/// The context marker. Zero-sized, stored in a plain `static`.
pub static MODE: Context<Mode> = Context::new();

/// A box that reads the nearest `MODE` provider, falling back to `Light`
/// when no provider is active. `css!` takes literal CSS, so the `if`/`else`
/// picks one of two literal blocks — no dynamic interpolation needed.
///
/// This leaf consumer defines no provider of its own; it reads whatever
/// ancestor provider was pushed in another module, proving a `Context<T>`
/// marker crosses file boundaries for free.
pub fn themed_box(label: &str) -> impl gpui::IntoElement {
    let mode = use_context_or(&MODE, || Mode::Light);
    let style = if matches!(mode, Mode::Dark) {
        css! {
            background: #1a1a2a;
            color: #ffffff;
            padding: 16px;
            margin: 8px;
            border-radius: 8px;
        }
    } else {
        css! {
            background: #f5f5f5;
            color: #111111;
            padding: 16px;
            margin: 8px;
            border-radius: 8px;
        }
    };
    view! {
        <div style={style}>{label.to_string()}</div>
    }
}
}

panel.rs — a sub-component in its own module that imports the marker from theme.rs, reads the ancestor provider, and nests its own override:

#![allow(unused)]
fn main() {
use crate::theme::{themed_box, Mode, MODE};
use vgui::prelude::*;

/// A sub-component living in its own module. It imports the `MODE` marker
/// from `theme.rs`, reads the ancestor provider set in `main.rs`, and nests
/// its own override provider — demonstrating that context resolution is
/// per-render, not per-module.
#[allow(non_snake_case)]
pub fn ThemePanel() -> impl gpui::IntoElement {
    view! {
        <div class="flex flex-col gap-2">
            {themed_box("panel: inherits root context")}
            <Provider context={MODE} value={Mode::Dark}>
                {themed_box("panel: overridden to dark")}
            </Provider>
        </div>
    }
}
}

main.rs — the root provider and dual entry point:

#![cfg_attr(target_family = "wasm", no_main)]

mod panel;
mod theme;

use gpui::{px, size, App, Bounds, WindowBounds, WindowOptions};
use panel::ThemePanel;
use theme::{themed_box, Mode, MODE};
use vgui::prelude::*;

#[cfg(not(target_family = "wasm"))]
use gpui_platform::application;

#[cfg(target_family = "wasm")]
use gpui_platform::single_threaded_web;

fn app() -> impl gpui::IntoElement {
    let (mode, set_mode) = create_signal(Mode::Light);
    view! {
        <Provider context={MODE} value={mode.get()}>
            <div class="flex flex-col p-4 gap-2 w-[400px] h-[400px]">
                {themed_box("root context (toggles)")}
                <ThemePanel />
                <button class="p-2 bg-[#0066cc] text-white rounded"
                    on:click={click(move |cx| set_mode.update(cx, |m|
                        *m = match *m { Mode::Light => Mode::Dark, Mode::Dark => Mode::Light }))}>
                    {"toggle root theme"}
                </button>
            </div>
        </Provider>
    }
}

fn run() {
    #[cfg(not(target_family = "wasm"))]
    let gpui_app = application();

    #[cfg(target_family = "wasm")]
    let gpui_app = single_threaded_web();

    let launch = |cx: &mut App| {
        let bounds = Bounds::centered(None, size(px(400.), px(400.0)), cx);
        cx.open_window(
            WindowOptions {
                window_bounds: Some(WindowBounds::Windowed(bounds)),
                ..Default::default()
            },
            |window, cx| vgui::mount(window, cx, app),
        )
        .unwrap();
    };

    #[cfg(not(target_family = "wasm"))]
    gpui_app.run(launch);

    #[cfg(target_family = "wasm")]
    std::mem::forget(gpui_app.run_embedded(launch));
}

#[cfg(not(target_family = "wasm"))]
fn main() {
    run();
}

#[cfg(target_family = "wasm")]
#[wasm_bindgen::prelude::wasm_bindgen(start)]
pub fn start() {
    gpui_platform::web_init();
    vgui::intercept_keyboard_events();
    run();
}

Key Concepts

Context::new() static

Context<T> is a zero-sized, const-constructable typed marker. It carries no value itself — it only identifies a context type, keyed by TypeId of T. Declare it in a plain static:

#![allow(unused)]
fn main() {
static MODE: Context<Mode> = Context::new();
}

One context per type. If you need two contexts of the same logical type, wrap the value in a newtype (struct Alt(Mode);) and declare a second Context<Alt>.

<Provider context={} value={}>

The <Provider> builtin pushes a value onto a thread-local stack before evaluating its children and pops it after. Descendants constructed between enter and exit observe the value via use_context. The context and value attributes are both required; any other attribute is rejected.

The type of value must match the context’s T__provider_scope_enter unifies T from both arguments, so a mismatch is a compile error.

use_context / use_context_or

use_context(&CTX) walks the provider stack top-down and returns the nearest matching entry, or None if no provider is active. use_context_or(&CTX, || default) falls back to the closure when no provider is present.

Nearest-ancestor resolution & nested override

The stack is searched top-down, so a nested <Provider> shadows an outer one within its subtree. In the example, the root provider binds MODE to a signal-driven Mode (toggled by the button), while an inner provider overrides it to Mode::Dark — so the “overridden to dark” box stays dark regardless of the toggle, while the “root context” box follows the signal.

Cross-module context

A Context<T> marker is a static keyed by TypeId on a thread-local provider stack. The stack is per-render, not per-module: a provider pushed in one file is visible to any descendant component constructed during that render, regardless of which module defines it. So a use crate::theme::MODE in panel.rs (or any other module) sees the nearest ancestor <Provider> that was entered in main.rs — no wiring, re-export, or parameter passing required. The three-file split in this example makes that flow visible in source: theme.rs owns the marker and a leaf consumer, panel.rs imports the marker and both consumes and provides, and main.rs pushes the root provider.

Programmatic provider (provide_context)

For advanced manual use and tests, provide_context(&CTX, value) returns a ProviderGuard that pops the stack on drop (RAII). The <Provider> macro builtin is the primary mechanism; provide_context is for cases where you need to provide a value outside a view! tree.

Running

Native:

cargo run -p vgui-context

Web (WASM):

cargo build --target wasm32-unknown-unknown -p vgui-context --release
wasm-bindgen --target web --out-dir examples/context/dist \
    --no-typescript target/wasm32-unknown-unknown/release/context.wasm
python3 scripts/serve_plain.py 8080 examples/context

Refs & NodeRef Example

Live Demo

Overview

The refs example demonstrates imperative access to rendered DOM nodes via NodeRef. It shows:

  • NodeRef::new() to create empty ref shells before the view is rendered.
  • ref= attribute binding to attach a ref to a specific element.
  • scroll_to_bottom() to imperatively scroll a container to its last child.
  • scroll_to(ix) to scroll to a specific child index.
  • focus(window, cx) to programmatically focus a focusable element.
  • bounds() returning Bounds<Pixels> with origin and size from the previous paint.
  • <Show> for conditional display of the bounds readout.

Source Code

#![cfg_attr(target_family = "wasm", no_main)]

use gpui::{px, size, App, Bounds, Pixels, WindowBounds, WindowOptions};
use vgui::prelude::*;

#[cfg(not(target_family = "wasm"))]
use gpui_platform::application;

#[cfg(target_family = "wasm")]
use gpui_platform::single_threaded_web;

fn app() -> impl gpui::IntoElement {
    // Create NodeRefs before view! — they're empty shells until bound
    // during render by the `ref=` attribute.
    let scroll_ref = NodeRef::new();
    let focus_ref = NodeRef::new();
    let items: Vec<u32> = (0..20).collect();
    let (bounds_text, set_bounds_text) = create_signal(String::new());
    let bounds_ref = NodeRef::new();

    // Clone refs for the event-handler closures (the ref= attributes
    // below consume separate clones).
    let scroll_ref_btn1 = scroll_ref.clone();
    let scroll_ref_btn2 = scroll_ref.clone();
    let focus_ref_btn = focus_ref.clone();
    let bounds_ref_btn = bounds_ref.clone();

    view! {
        <div class="flex flex-col gap-2 p-4 bg-[#505050] w-[400px] h-[500px] text-white">
            <h2 class="text-lg font-bold">{"Refs Demo"}</h2>

            // Buttons that call imperative methods on the refs.
            <div class="flex gap-2">
                <button
                    class="p-2 bg-[#0000ff] hover:bg-[#000088] rounded text-white"
                    on:click={click(move |_cx| {
                        scroll_ref_btn1.scroll_to_bottom();
                    })}
                >
                    {"Scroll to bottom"}
                </button>
                <button
                    class="p-2 bg-[#006600] hover:bg-[#004400] rounded text-white"
                    on:click={click(move |_cx| {
                        scroll_ref_btn2.scroll_to(2);
                    })}
                >
                    {"Scroll to #2"}
                </button>
                <button
                    class="p-2 bg-[#660066] hover:bg-[#440044] rounded text-white"
                    on:click={move |_e, window, cx| {
                        focus_ref_btn.focus(window, cx);
                    }}
                >
                    {"Focus box"}
                </button>
                <button
                    class="p-2 bg-[#0066cc] hover:bg-[#004499] rounded text-white"
                    on:click={move |_e, _window, _cx| {
                        let b = bounds_ref_btn.bounds();
                        set_bounds_text.set(_cx, format!(
                            "x: {:.0} y: {:.0} w: {:.0} h: {:.0}",
                            f32::from(b.origin.x),
                            f32::from(b.origin.y),
                            f32::from(b.size.width),
                            f32::from(b.size.height),
                        ));
                    }}
                >
                    {"Get bounds"}
                </button>
            </div>

            // A scrollable list bound to scroll_ref via ref=.
            // ref= forces an auto-id and applies track_focus + track_scroll
            // so scroll_to/scroll_to_bottom/bounds all work.
            <div
                ref={scroll_ref.clone()}
                class="flex-1 overflow-y-scroll bg-[#3a3a3a] rounded p-2 gap-1 flex-col"
            >
                <For each={items}>
                    {move |i: u32, _idx: usize| view! {
                        <div class="p-2 bg-[#2a2a2a] rounded">
                            {format!("Item {}", i)}
                        </div>
                    }}
                </For>
            </div>

            // A focusable box bound to focus_ref.
            <div
                ref={focus_ref.clone()}
                class="p-3 bg-[#2a2a2a] rounded border-2 border-[#666] focus:border-[#0f0]"
                tabindex={0}
            >
                {"Click 'Focus box' to focus me."}
            </div>

            // A div bound to bounds_ref — click "Get bounds" to read its
            // painted Bounds<Pixels> (origin + size) from the previous frame.
            <div
                ref={bounds_ref.clone()}
                class="p-3 bg-[#2a2a2a] rounded border-2 border-[#444]"
            >
                {"Bounds target div"}
            </div>

            <Show when={!bounds_text.get().is_empty()}>
                <span class="text-sm text-[#0f0]">{bounds_text.get()}</span>
            </Show>
        </div>
    }
}

fn run() {
    #[cfg(not(target_family = "wasm"))]
    let gpui_app = application();

    #[cfg(target_family = "wasm")]
    let gpui_app = single_threaded_web();

    let launch = |cx: &mut App| {
        let bounds = Bounds::centered(None, size(px(400.), px(500.0)), cx);
        cx.open_window(
            WindowOptions {
                window_bounds: Some(WindowBounds::Windowed(bounds)),
                ..Default::default()
            },
            |window, cx| vgui::mount(window, cx, app),
        )
        .unwrap();
    };

    #[cfg(not(target_family = "wasm"))]
    gpui_app.run(launch);

    #[cfg(target_family = "wasm")]
    std::mem::forget(gpui_app.run_embedded(launch));
}

#[cfg(not(target_family = "wasm"))]
fn main() {
    run();
}

#[cfg(target_family = "wasm")]
#[wasm_bindgen::prelude::wasm_bindgen(start)]
pub fn start() {
    gpui_platform::web_init();
    vgui::intercept_keyboard_events();
    run();
}

Key Concepts

NodeRef lifecycle

NodeRef::new() creates an empty ref shell before the view! macro runs. During render, the ref={scroll_ref.clone()} attribute binds the shell to the actual rendered element, forcing an auto-id and enabling track_focus + track_scroll internally. Because event-handler closures capture the ref by move, you clone the ref once per closure (and once per ref= binding) — each clone shares the same underlying node handle.

Imperative scroll methods

scroll_to_bottom() scrolls the bound container so its last child is visible. scroll_to(ix) scrolls to child index ix. Both operate on the element bound via ref= and require the container to have overflow scrolling enabled (here overflow-y-scroll). These are called from click(move |_cx| ...) handlers — no window or cx argument is needed for scroll operations.

Programmatic focus

focus_ref_btn.focus(window, cx) moves keyboard focus to the element bound by ref=. Unlike scroll, focus needs the window and cx from the event handler signature, so this button uses the raw move |_e, window, cx| form rather than the click helper. The target div has tabindex={0} so it is focusable, and a focus: variant changes its border color when focused.

bounds() returning Bounds<Pixels>

bounds_ref_btn.bounds() returns a Bounds<Pixels> struct describing the element’s painted rectangle from the previous frame. It exposes .origin (a Point<Pixels> with .x and .y) and .size (with .width and .height). The “Get bounds” button reads these fields, formats them into a string, and stores it in a signal. A <Show> block conditionally renders the readout only once it is non-empty. Convert Pixels to f32 via f32::from(b.origin.x) for formatting.

<Show> for conditional display

<Show when={!bounds_text.get().is_empty()}> renders the bounds readout span only after the user has clicked “Get bounds” at least once. Before that, the signal holds an empty string and the <Show> renders nothing.

Running

Native:

cargo run -p vgui-refs

Web (WASM):

cargo build --target wasm32-unknown-unknown -p vgui-refs --release
wasm-bindgen --target web --out-dir examples/refs/dist \
    --no-typescript target/wasm32-unknown-unknown/release/refs.wasm
python3 scripts/serve_plain.py 8080 examples/refs

Focus Management Example

Live Demo

Overview

The focus example demonstrates keyboard focus management patterns in vgui. It shows:

  • Dialog with focus trap and focus restore on close.
  • Radiogroup with roving tabindex (Tab reaches only the checked radio).
  • on:resize / ResizeEvent for tracking window size changes.
  • Tab / Shift+Tab cycling within the dialog’s focus trap.
  • Arrow-key navigation between radio options.

Source Code

#![cfg_attr(target_family = "wasm", no_main)]

use gpui::{px, size, App, Bounds, WindowBounds, WindowOptions};
use vgui::prelude::*;

#[cfg(not(target_family = "wasm"))]
use gpui_platform::application;

#[cfg(target_family = "wasm")]
use gpui_platform::single_threaded_web;

fn app() -> impl gpui::IntoElement {
    let (dialog_open, set_dialog_open) = create_signal(false);
    let (radio_val, set_radio) = create_signal(0i32);
    let (field1, set_field1) = create_signal(String::new());
    let (field2, set_field2) = create_signal(String::new());
    let (size_sig, set_size) = create_signal((0f64, 0f64));

    // Individual setters for radio on:change closures (each needs its own
    // WriteSignal clone with a 'static lifetime).
    let sr0 = set_radio.clone();
    let sr1 = set_radio.clone();
    let sr2 = set_radio.clone();
    let set_dialog_open_btn = set_dialog_open.clone();
    let set_dialog_close = set_dialog_open.clone();
    let set_dialog_close_btn = set_dialog_open.clone();

    view! {
        <div class="flex flex-col gap-4 p-6 bg-[#505050] w-[600px] h-[500px] text-white"
            on:resize={move |ev: &ResizeEvent, _w, _cx| { set_size.update(_cx, |_| (ev.width, ev.height)); }}
        >
            <span class="text-sm text-[#0f0]">{format!("{:.0} x {:.0}", size_sig.get().0, size_sig.get().1)}</span>

            // ── Dialog with focus trap + restore ──────────────────────
            <div class="flex flex-col gap-2">
                <span class="text-sm text-[#aaa]">
                    {"Click the button, then Tab/Shift+Tab to cycle within the dialog. Escape or click-outside closes it and restores focus."}
                </span>
                <button
                    class="px-3 py-2 bg-[#0066cc] hover:bg-[#004499] rounded text-sm"
                    on:click={click(move |cx| set_dialog_open_btn.set(cx, true))}
                >
                    {"Open Dialog"}
                </button>
            </div>

            // ── Radio group with roving tabindex ──────────────────────
            <div class="flex flex-col gap-2">
                <span class="text-sm text-[#aaa]">
                    {"Tab reaches only the checked radio. Arrow keys move between radios."}
                </span>
                <radiogroup>
                    <div class="flex flex-row gap-4 items-center">
                        <div class="flex flex-row gap-1 items-center">
                            <input type="radio" checked={radio_val.get() == 0} on:change={move |_v: bool, cx: &mut App| sr0.set(cx, 0)} />
                            <span class="text-sm">{"Option A"}</span>
                        </div>
                        <div class="flex flex-row gap-1 items-center">
                            <input type="radio" checked={radio_val.get() == 1} on:change={move |_v: bool, cx: &mut App| sr1.set(cx, 1)} />
                            <span class="text-sm">{"Option B"}</span>
                        </div>
                        <div class="flex flex-row gap-1 items-center">
                            <input type="radio" checked={radio_val.get() == 2} on:change={move |_v: bool, cx: &mut App| sr2.set(cx, 2)} />
                            <span class="text-sm">{"Option C"}</span>
                        </div>
                    </div>
                </radiogroup>
                <span class="text-sm text-[#0f0]">{format!("selected: {}", radio_val.get())}</span>
            </div>

            // ── Dialog content ────────────────────────────────────────
            <dialog open={dialog_open.get()} on:close={move |cx| set_dialog_close.set(cx, false)}>
                <div class="bg-white text-black p-5 rounded-lg flex flex-col gap-3 w-[350px]">
                    <h3 class="font-bold">{"Dialog with Focus Trap"}</h3>
                    <div class="flex flex-col gap-1">
                        <span class="text-sm text-[#666]">{"Field 1 (type text)"}</span>
                        <input
                            type="text"
                            placeholder="First field"
                            value={field1.get()}
                            on:input={move |v: &str, cx: &mut App| set_field1.set(cx, v.to_string())}
                        />
                    </div>
                    <label class="flex flex-col gap-1">
                        <span class="text-sm text-[#666]">{"Field 2 (type text)"}</span>
                        <input
                            type="text"
                            placeholder="Second field"
                            value={field2.get()}
                            on:input={move |v: &str, cx: &mut App| set_field2.set(cx, v.to_string())}
                        />
                    </label>
                    <div class="flex flex-row gap-2 justify-end">
                        <button
                            class="px-3 py-2 bg-[#ccc] hover:bg-[#aaa] rounded text-sm"
                            on:click={click(move |cx| set_dialog_close_btn.set(cx, false))}
                        >
                            {"Close"}
                        </button>
                    </div>
                </div>
            </dialog>
        </div>
    }
}

fn run() {
    #[cfg(not(target_family = "wasm"))]
    let gpui_app = application();

    #[cfg(target_family = "wasm")]
    let gpui_app = single_threaded_web();

    let launch = |cx: &mut App| {
        let bounds = Bounds::centered(None, size(px(600.), px(500.0)), cx);
        cx.open_window(
            WindowOptions {
                window_bounds: Some(WindowBounds::Windowed(bounds)),
                ..Default::default()
            },
            |window, cx| vgui::mount(window, cx, app),
        )
        .unwrap();
    };

    #[cfg(not(target_family = "wasm"))]
    gpui_app.run(launch);

    #[cfg(target_family = "wasm")]
    std::mem::forget(gpui_app.run_embedded(launch));
}

#[cfg(not(target_family = "wasm"))]
fn main() {
    run();
}

#[cfg(target_family = "wasm")]
#[wasm_bindgen::prelude::wasm_bindgen(start)]
pub fn start() {
    gpui_platform::web_init();
    vgui::intercept_keyboard_events();
    run();
}

Key Concepts

Focus trap via dialog()

The <dialog open={...}> element renders on a deferred overlay layer with a backdrop. While open, it traps keyboard focus: Tab and Shift+Tab cycle only among the focusable elements inside the dialog (the two text inputs and the Close button). Focus cannot escape to the underlying page until the dialog is dismissed.

Focus restore on close

When the dialog closes — via the Close button, the Escape key, or clicking outside the dialog — focus is automatically restored to the element that had focus before the dialog opened (the “Open Dialog” button). The on:close handler resets the dialog_open signal to false, which unmounts the dialog content and triggers the restore.

Roving tabindex via radiogroup

The <radiogroup> wrapper implements the WAI-ARIA roving tabindex pattern. Only the currently checked radio is in the tab order (tabindex=0); the others are removed (tabindex=-1). Tab moves into the group at the checked radio, then Tab again leaves the group. Arrow keys move the selection between options without leaving the group. Each radio’s on:change handler updates the shared radio_val signal, which reactively updates which radio is checked.

ResizeEvent for window size tracking

The root <div> carries on:resize={move |ev: &ResizeEvent, _w, _cx| ...}. The ResizeEvent provides .width and .height (in device pixels). The handler stores these into the size_sig signal, and a span at the top of the view displays the current dimensions. Resizing the window updates the display in real time.

Running

Native:

cargo run -p vgui-focus

Web (WASM):

cargo build --target wasm32-unknown-unknown -p vgui-focus --release
wasm-bindgen --target web --out-dir examples/focus/dist \
    --no-typescript target/wasm32-unknown-unknown/release/focus.wasm
python3 scripts/serve_plain.py 8080 examples/focus

Overlays Example

Live Demo

Overview

The overlays example demonstrates the three overlay primitives in vgui and how to gate them with conditional rendering:

  • dialog() — a modal with a focus trap, backdrop, and Escape / click-outside dismissal. The signature is (open: bool, on_close: impl Fn(&mut App), content).
  • floating() — renders content at a fixed Point<Pixels> position, detached from the normal flow.
  • portal() — lifts content onto a high-priority deferred layer with a numeric priority argument.
  • show() — conditionally renders one of two branches (then / fallback) based on a reactive boolean.
  • gpui::Empty as an IntoElement fallback so show() renders nothing when the overlay is closed.

Source Code

#![cfg_attr(target_family = "wasm", no_main)]

use gpui::{point, px, size, App, Bounds, Empty, WindowBounds, WindowOptions};
use vgui::{dialog, show};
use vgui::prelude::*;

#[cfg(not(target_family = "wasm"))]
use gpui_platform::application;

#[cfg(target_family = "wasm")]
use gpui_platform::single_threaded_web;

fn app() -> impl gpui::IntoElement {
    let (dialog_open, set_dialog_open) = create_signal(false);
    let (floating_open, set_floating_open) = create_signal(false);
    let (portal_open, set_portal_open) = create_signal(false);
    let set_dialog_close = set_dialog_open.clone();
    let set_dialog_confirm = set_dialog_open.clone();
    let set_dialog_cancel = set_dialog_open.clone();

    view! {
        <div class="flex flex-col gap-4 p-6 bg-[#1a1a2e] text-white" style={css!{ width: 500px; height: 500px; }}>
            <h2 class="text-lg font-bold">{"Overlays Example"}</h2>

            // ── Dialog ───────────────────────────────────────────────
            <div class="flex flex-col gap-2">
                <span class="text-sm text-[#888]">{"dialog() — modal with focus trap + backdrop"}</span>
                <button
                    class="px-4 py-2 bg-[#2563ff] rounded text-white"
                    on:click={click(move |cx| set_dialog_open.set(cx, true))}
                >
                    {"Open Dialog"}
                </button>
                {dialog(dialog_open.get(), move |cx| set_dialog_close.set(cx, false), view! {
                    <div class="bg-[#2d2d44] p-6 rounded-lg text-white" style={css!{ max-width: 300px; }}>
                        <h3 class="font-bold mb-2">{"Confirm Action"}</h3>
                        <p class="text-sm mb-4">{"Are you sure you want to proceed?"}</p>
                        <div class="flex gap-3 mt-4">
                            <button
                                class="px-4 py-2 bg-[#2563ff] rounded text-white"
                                on:click={click(move |cx| set_dialog_confirm.set(cx, false))}
                            >
                                {"Confirm"}
                            </button>
                            <button
                                class="px-4 py-2 bg-[#6c757d] rounded text-white"
                                on:click={click(move |cx| set_dialog_cancel.set(cx, false))}
                            >
                                {"Cancel"}
                            </button>
                        </div>
                    </div>
                })}
            </div>

            // ── Floating ─────────────────────────────────────────────
            <div class="flex flex-col gap-2">
                <span class="text-sm text-[#888]">{"floating() — positioned at (150, 200)"}</span>
                <button
                    class="px-4 py-2 bg-[#10b981] rounded text-white"
                    on:click={click(move |cx| set_floating_open.update(cx, |v| *v = !*v))}
                >
                    {"Toggle Floating"}
                </button>
                {show(floating_open.get(), floating(point(px(150.), px(200.)), view! {
                    <div class="bg-[#2d2d44] p-3 rounded text-white text-sm">
                        {"This is a floating element positioned at (150, 200)"}
                    </div>
                }), gpui::Empty)}
            </div>

            // ── Portal ───────────────────────────────────────────────
            <div class="flex flex-col gap-2">
                <span class="text-sm text-[#888]">{"portal() — high-priority deferred layer"}</span>
                <button
                    class="px-4 py-2 bg-[#9933ff] rounded text-white"
                    on:click={click(move |cx| set_portal_open.update(cx, |v| *v = !*v))}
                >
                    {"Toggle Portal"}
                </button>
                {show(portal_open.get(), portal(view! {
                    <div class="bg-[#2563ff] p-4 rounded text-white" style={css!{ position: absolute; top: 20px; right: 20px; }}>
                        {"Portaled content on a high-priority layer"}
                    </div>
                }, 50), gpui::Empty)}
            </div>
        </div>
    }
}

fn run() {
    #[cfg(not(target_family = "wasm"))]
    let gpui_app = application();

    #[cfg(target_family = "wasm")]
    let gpui_app = single_threaded_web();

    let launch = |cx: &mut App| {
        let bounds = Bounds::centered(None, size(px(500.), px(500.0)), cx);
        cx.open_window(
            WindowOptions {
                window_bounds: Some(WindowBounds::Windowed(bounds)),
                ..Default::default()
            },
            |window, cx| vgui::mount(window, cx, app),
        )
        .unwrap();
    };

    #[cfg(not(target_family = "wasm"))]
    gpui_app.run(launch);

    #[cfg(target_family = "wasm")]
    std::mem::forget(gpui_app.run_embedded(launch));
}

#[cfg(not(target_family = "wasm"))]
fn main() {
    run();
}

#[cfg(target_family = "wasm")]
#[wasm_bindgen::prelude::wasm_bindgen(start)]
pub fn start() {
    gpui_platform::web_init();
    vgui::intercept_keyboard_events();
    run();
}

Key Concepts

dialog() — modal with focus trap and backdrop

dialog(open, on_close, content) renders a modal when open is true. The dialog is placed on a deferred layer above all normal content, paints a semi-transparent backdrop, traps keyboard focus inside the dialog while it is open, and restores focus to the previously focused element on close. It dismisses on Escape and on backdrop click, calling on_close in both cases. Here on_close is move |cx| set_dialog_close.set(cx, false), so any dismissal path flips the signal and the dialog disappears on the next render.

floating() — positioned overlay

floating(point, content) renders content at an absolute Point<Pixels> position. The point is constructed with gpui::point(gpui::px(150.), gpui::px(200.)). Unlike dialog(), floating() has no backdrop or focus trap — it simply detaches content from the layout flow and paints it at the given coordinates.

portal() — high-priority deferred layer

portal(content, priority) lifts content onto a deferred layer with a numeric priority (here 50). Higher priority layers paint above lower ones, so portaled content always renders on top of the normal tree regardless of where the portal() call appears. The inner view! uses css!{ position: absolute; top: 20px; right: 20px; } to anchor itself in the top-right corner of the window.

show() for conditional overlays

show(when, then, fallback) renders then when when is true and fallback otherwise. Both branches must be impl IntoElement. For the floating and portal sections the fallback is gpui::Empty, which implements IntoElement and renders nothing — so the overlay simply vanishes when its toggle signal is false. The dialog() helper already handles its own visibility via the open boolean argument, so it does not need a show() wrapper.

gpui::Empty as an IntoElement fallback

gpui::Empty is a zero-sized type that implements gpui::IntoElement by producing no element. It is the idiomatic “render nothing” value for the fallback slot of show() when you only want content to appear conditionally with no alternative.

Running

Native:

cargo run -p vgui-overlays

Web (WASM):

cargo build --target wasm32-unknown-unknown -p vgui-overlays --release
wasm-bindgen --target web --out-dir examples/overlays/dist \
    --no-typescript target/wasm32-unknown-unknown/release/overlays.wasm
python3 scripts/serve_plain.py 8080 examples/overlays

Animation Example

Live Demo

Overview

This example demonstrates vgui’s animation and transition support:

  • animate-pulse / animate-bounce / animate-ping keyframe animations.
  • transition-opacity and transition-colors with duration-* / ease-* timing.
  • Custom animate={...} attribute for user-defined keyframe animations.

Source Code

#![cfg_attr(target_family = "wasm", no_main)]

use std::time::Duration;

use gpui::{px, size, App, Bounds, WindowBounds, WindowOptions, AnimationExt};
use vgui::prelude::*;

#[cfg(not(target_family = "wasm"))]
use gpui_platform::application;

#[cfg(target_family = "wasm")]
use gpui_platform::single_threaded_web;

fn app() -> impl gpui::IntoElement {
    view! {
        <div class="flex flex-col gap-6 p-6 bg-[#1a1a2e] w-full h-full text-white overflow-y-auto">
            <h2 class="text-lg font-bold">{"Animations & Transitions"}</h2>

            // ── Built-in keyframe animations ───────────────────────────
            <div class="flex flex-col gap-2">
                <span class="text-sm text-[#aaa]">{"animate-pulse"}</span>
                <div class="bg-[#3b82f6] rounded p-4 animate-pulse">
                    {"Pulsing"}
                </div>
            </div>

            <div class="flex flex-col gap-2">
                <span class="text-sm text-[#aaa]">{"animate-bounce"}</span>
                <div class="bg-[#10b981] rounded p-4 animate-bounce">
                    {"Bouncing"}
                </div>
            </div>

            <div class="flex flex-col gap-2">
                <span class="text-sm text-[#aaa]">{"animate-ping"}</span>
                <div class="bg-[#ef4444] rounded p-4 animate-ping">
                    {"Pinging"}
                </div>
            </div>

            // ── Transitions on hover ───────────────────────────────────
            <div class="flex flex-col gap-2">
                <span class="text-sm text-[#aaa]">{"transition-opacity hover:opacity-50 duration-300"}</span>
                <button class="bg-[#6366f1] hover:opacity-50 rounded p-3 transition-opacity duration-300">
                    {"Fade on hover"}
                </button>
            </div>

            <div class="flex flex-col gap-2">
                <span class="text-sm text-[#aaa]">{"transition-colors hover:bg-[#2563eb] duration-300"}</span>
                <button class="bg-[#6366f1] hover:bg-[#2563eb] rounded p-3 transition-colors duration-300 ease-in-out">
                    {"Color on hover"}
                </button>
            </div>

            // ── Custom animation via animate={...} ─────────────────────
            <div class="flex flex-col gap-2">
                <span class="text-sm text-[#aaa]">{"animate={...} custom"}</span>
                <div
                    class="bg-[#f59e0b] rounded p-4"
                    animate={|el| el.with_animation(
                        "custom-breath",
                        gpui::Animation::new(Duration::from_millis(1500))
                            .repeat()
                            .with_easing(gpui::ease_in_out),
                        |mut el, delta| {
                            el = el.opacity(0.4 + 0.6 * (delta * std::f32::consts::PI).sin());
                            el
                        },
                    )}
                >
                    {"Custom breathing"}
                </div>
            </div>
        </div>
    }
}

fn run() {
    #[cfg(not(target_family = "wasm"))]
    let gpui_app = application();

    #[cfg(target_family = "wasm")]
    let gpui_app = single_threaded_web();

    let launch = |cx: &mut App| {
        let bounds = Bounds::centered(None, size(px(520.), px(640.0)), cx);
        cx.open_window(
            WindowOptions {
                window_bounds: Some(WindowBounds::Windowed(bounds)),
                ..Default::default()
            },
            |window, cx| vgui::mount(window, cx, app),
        )
        .unwrap();
    };

    #[cfg(not(target_family = "wasm"))]
    gpui_app.run(launch);

    #[cfg(target_family = "wasm")]
    std::mem::forget(gpui_app.run_embedded(launch));
}

#[cfg(not(target_family = "wasm"))]
fn main() {
    run();
}

#[cfg(target_family = "wasm")]
#[wasm_bindgen::prelude::wasm_bindgen(start)]
pub fn start() {
    gpui_platform::web_init();
    vgui::intercept_keyboard_events();
    run();
}

Key Concepts

Built-in animations

animate-pulse, animate-bounce, and animate-ping are Tailwind-compatible classes parsed at compile time by tw!. Each maps to a gpui Animation with a repeating loop and a custom animator closure.

Transitions

transition-opacity / transition-colors animate between the base style and the hover: style when the pointer enters or leaves the element. The view! macro creates a hover signal, registers on_hover, and calls apply_transition to interpolate the relevant properties.

Custom animate={...}

The animate attribute accepts a closure |el| -> AnimationElement<E>. This gives full access to gpui’s with_animation API for custom keyframes, durations, easings, and repeat behavior.

Running

Native:

cargo run -p vgui-animation

Web (WASM):

# Build the WASM binary
cargo +nightly build --target wasm32-unknown-unknown -p vgui-animation --release

# Generate JS bindings
wasm-bindgen --target web --out-dir examples/animation/dist \
    --no-typescript target/wasm32-unknown-unknown/release/animation.wasm

# Serve and open in a browser
python3 scripts/serve_plain.py 8080 examples/animation

Canvas Example

Live Demo

Overview

The canvas example demonstrates the <canvas> element and the Context2D 2D drawing API. It shows:

  • fill_rect and stroke_rect with different colors and line widths.
  • Path drawing: begin_path, move_to, line_to, arc, close_path, fill, stroke.
  • fill_text with font setting and text alignment.
  • save/restore with translate/rotate for a rotated shape.
  • The color() runtime CSS color parser for hex strings.

Source Code

#![cfg_attr(target_family = "wasm", no_main)]

use gpui::{px, size, App, Bounds, WindowBounds, WindowOptions};
use vgui::prelude::*;

#[cfg(not(target_family = "wasm"))]
use gpui_platform::application;

#[cfg(target_family = "wasm")]
use gpui_platform::single_threaded_web;

fn app() -> impl gpui::IntoElement {
    view! {
        <div class="flex flex-col items-center justify-center bg-[#1e1e2e] w-full h-full">
            <canvas
                class="w-[400px] h-[300px] bg-[#2d2d44]"
                paint={move |ctx: &mut Context2D| {
                    // Red filled rectangle
                    ctx.set_fill_style(color("#ff0000"));
                    ctx.fill_rect(10.0, 10.0, 80.0, 50.0);

                    // Blue stroked rectangle
                    ctx.set_stroke_style(color("#0000ff"));
                    ctx.set_line_width(3.0);
                    ctx.stroke_rect(110.0, 10.0, 80.0, 50.0);

                    // Green filled circle (arc path)
                    ctx.set_fill_style(color("#00cc44"));
                    ctx.begin_path();
                    ctx.arc(60.0, 130.0, 30.0, 0.0, std::f32::consts::TAU, false);
                    ctx.fill();

                    // Yellow stroked triangle (line path)
                    ctx.set_stroke_style(color("#ffdd00"));
                    ctx.set_line_width(2.0);
                    ctx.begin_path();
                    ctx.move_to(150.0, 100.0);
                    ctx.line_to(120.0, 160.0);
                    ctx.line_to(180.0, 160.0);
                    ctx.close_path();
                    ctx.stroke();

                    // "Hello Canvas" text in white
                    ctx.set_fill_style(color("#ffffff"));
                    ctx.set_font("16px sans-serif");
                    ctx.fill_text("Hello Canvas", 10.0, 200.0);

                    // Rotated purple square (save/restore/rotate)
                    ctx.save();
                    ctx.translate(300.0, 100.0);
                    ctx.rotate(std::f32::consts::FRAC_PI_4);
                    ctx.set_fill_style(color("#9933ff"));
                    ctx.fill_rect(-25.0, -25.0, 50.0, 50.0);
                    ctx.restore();

                    // Text alignment demo
                    ctx.set_font("12px sans-serif");
                    ctx.set_fill_style(color("#aaaaaa"));
                    ctx.set_text_align(CanvasTextAlign::Center);
                    ctx.fill_text("centered text", 200.0, 260.0);
                }}
            />
        </div>
    }
}

fn run() {
    #[cfg(not(target_family = "wasm"))]
    let gpui_app = application();

    #[cfg(target_family = "wasm")]
    let gpui_app = single_threaded_web();

    let launch = |cx: &mut App| {
        let bounds = Bounds::centered(None, size(px(440.), px(360.0)), cx);
        cx.open_window(
            WindowOptions {
                window_bounds: Some(WindowBounds::Windowed(bounds)),
                ..Default::default()
            },
            |window, cx| vgui::mount(window, cx, app),
        )
        .unwrap();
    };

    #[cfg(not(target_family = "wasm"))]
    gpui_app.run(launch);

    #[cfg(target_family = "wasm")]
    std::mem::forget(gpui_app.run_embedded(launch));
}

#[cfg(not(target_family = "wasm"))]
fn main() {
    run();
}

#[cfg(target_family = "wasm")]
#[wasm_bindgen::prelude::wasm_bindgen(start)]
pub fn start() {
    gpui_platform::web_init();
    vgui::intercept_keyboard_events();
    run();
}

Key Concepts

<canvas> element

The <canvas> element takes a paint closure that receives a &mut Context2D. The closure runs every frame during gpui’s paint phase (immediate mode). The element supports class and style for sizing and background, but cannot have children or event handlers.

Context2D API

Context2D provides a web-like 2D drawing context:

  • Rectangles: fill_rect, stroke_rect, clear_rect (no-op in immediate mode).
  • Paths: begin_path, move_to, line_to, quadratic_curve_to, bezier_curve_to, arc, close_path, fill, stroke.
  • Text: fill_text, measure_text (returns TextMetrics). stroke_text is a no-op (gpui has no text outline API).
  • State: fill_style, stroke_style, line_width, font, text_align, global_alpha.
  • Transforms: save, restore, translate, rotate, scale, set_transform, reset_transform.

color() parser

The color() function parses CSS color strings at runtime into Hsla: hex (#rgb, #rrggbb, #rrggbbaa), rgb(), rgba(), hsl(), hsla(), named colors, and "transparent".

Running

Native:

cargo run -p vgui-canvas

Web (WASM):

# Build the WASM binary
cargo build --target wasm32-unknown-unknown -p vgui-canvas --release

# Generate JS bindings
wasm-bindgen --target web --out-dir examples/canvas/dist \
    --no-typescript target/wasm32-unknown-unknown/release/canvas.wasm

# Serve and open in a browser
python3 scripts/serve_plain.py 8080 examples/canvas

Router Example

Live Demo

Overview

The router example demonstrates the vgui SPA router with parameterized route matching, programmatic navigation, and a 404 fallback:

  • create_router("/") — creates a router with an initial path.
  • router.navigate(cx, path) — updates the current path inside an event handler (where cx: &mut App is available).
  • router.match_route(pattern) — reactively reads router.path() and returns Option<RouteMatch>, matching static segments and named params like /users/:id.
  • RouteMatch::params — a HashMap<String, String> of extracted path parameters, used here to pull the id from /users/:id.
  • A cascade of if let Some(m) blocks for route dispatch, falling through to a 404 view when no pattern matches.
  • for_each for rendering the user list, with each item navigating to its detail route on click.

Source Code

#![cfg_attr(target_family = "wasm", no_main)]

use gpui::{px, size, App, Bounds, WindowBounds, WindowOptions};
use vgui::for_each;
use vgui::prelude::*;
use vgui::router::RouteMatch;

#[cfg(not(target_family = "wasm"))]
use gpui_platform::application;

#[cfg(target_family = "wasm")]
use gpui_platform::single_threaded_web;

fn route_content(router: &Router) -> gpui::AnyElement {
    if router.match_route("/").is_some() {
        return view! {
            <div class="p-4">
                <h2 class="text-lg font-bold">{"Home"}</h2>
                <p class="text-sm text-[#aaa]">{"Welcome to the router example."}</p>
            </div>
        }.into_any_element();
    }
    if router.match_route("/users").is_some() {
        let users = vec![
            ("1", "Alice"),
            ("2", "Bob"),
            ("3", "Charlie"),
        ];
        return view! {
            <div class="p-4">
                <h2 class="text-lg font-bold">{"Users"}</h2>
                <ul class="flex flex-col gap-1 mt-2">
                    {for_each(users, |(id, name), _| {
                        let router = router.clone();
                        view! {
                            <li
                                class="p-2 cursor-pointer hover:bg-[#333] rounded text-sm"
                                on:click={click(move |cx| router.navigate(cx, &format!("/users/{id}")))}
                            >
                                {name.to_string()}
                            </li>
                        }
                    })}
                </ul>
            </div>
        }.into_any_element();
    }
    if let Some(m) = router.match_route("/users/:id") {
        let id = m.params.get("id").cloned().unwrap_or_else(|| "?".to_string());
        let router = router.clone();
        return view! {
            <div class="p-4">
                <h2 class="text-lg font-bold">{format!("User #{}", id)}</h2>
                <p class="text-sm text-[#aaa]">{format!("Details for user {}", id)}</p>
                <button
                    class="px-3 py-1 bg-[#333] hover:bg-[#444] rounded text-white text-sm mt-2"
                    on:click={click(move |cx| router.navigate(cx, "/users"))}
                >
                    {"Back to list"}
                </button>
            </div>
        }.into_any_element();
    }
    if router.match_route("/settings").is_some() {
        return view! {
            <div class="p-4">
                <h2 class="text-lg font-bold">{"Settings"}</h2>
                <p class="text-sm text-[#aaa]">{"Settings page content."}</p>
            </div>
        }.into_any_element();
    }
    // Fallback 404
    view! {
        <div class="p-4">
            <h2 class="text-lg font-bold">{"404"}</h2>
            <p class="text-sm text-[#aaa]">{"Page not found."}</p>
        </div>
    }.into_any_element()
}

fn app() -> impl gpui::IntoElement {
    let router = create_router("/");
    let r_home = router.clone();
    let r_users = router.clone();
    let r_settings = router.clone();
    let r_path = router.clone();

    view! {
        <div class="flex flex-col bg-[#1a1a2e] text-white h-full">
            // Nav bar
            <div class="flex flex-row gap-2 p-3 bg-[#2d2d44]">
                <button
                    class="px-3 py-1 bg-[#333] hover:bg-[#444] rounded text-white text-sm"
                    on:click={click(move |cx| r_home.navigate(cx, "/"))}
                >
                    {"Home"}
                </button>
                <button
                    class="px-3 py-1 bg-[#333] hover:bg-[#444] rounded text-white text-sm"
                    on:click={click(move |cx| r_users.navigate(cx, "/users"))}
                >
                    {"Users"}
                </button>
                <button
                    class="px-3 py-1 bg-[#333] hover:bg-[#444] rounded text-white text-sm"
                    on:click={click(move |cx| r_settings.navigate(cx, "/settings"))}
                >
                    {"Settings"}
                </button>
            </div>

            // Current path display
            <div class="text-sm text-gray-400 p-2">
                {format!("Current path: {}", r_path.path())}
            </div>

            // Route content
            <div class="flex-1">
                {route_content(&router)}
            </div>
        </div>
    }
}

fn run() {
    #[cfg(not(target_family = "wasm"))]
    let gpui_app = application();

    #[cfg(target_family = "wasm")]
    let gpui_app = single_threaded_web();

    let launch = |cx: &mut App| {
        let bounds = Bounds::centered(None, size(px(600.), px(500.0)), cx);
        cx.open_window(
            WindowOptions {
                window_bounds: Some(WindowBounds::Windowed(bounds)),
                ..Default::default()
            },
            |window, cx| vgui::mount(window, cx, app),
        )
        .unwrap();
    };

    #[cfg(not(target_family = "wasm"))]
    gpui_app.run(launch);

    #[cfg(target_family = "wasm")]
    std::mem::forget(gpui_app.run_embedded(launch));
}

#[cfg(not(target_family = "wasm"))]
fn main() {
    run();
}

#[cfg(target_family = "wasm")]
#[wasm_bindgen::prelude::wasm_bindgen(start)]
pub fn start() {
    gpui_platform::web_init();
    vgui::intercept_keyboard_events();
    run();
}

Key Concepts

create_router() with initial path

create_router("/") constructs a Router backed by a signal holding the current path. The initial path is "/", so the home view renders on first paint. The router is Clone (it is a handle around shared state), so it can be cheaply cloned into each event handler closure that needs to navigate.

match_route() — reactive read via path()

router.match_route(pattern) calls router.path() internally, which performs a reactive signal read. Because the read happens inside the view! render scope, any later navigate() that changes the path triggers a re-render and the route content updates automatically — no manual subscription needed. match_route returns Option<RouteMatch>: Some when the current path matches the pattern (including named params), None otherwise.

RouteMatch::paramsHashMap<String, String>

For the pattern /users/:id, a match against /users/2 produces a RouteMatch whose params map contains {"id": "2"}. The detail view extracts it with m.params.get("id").cloned().unwrap_or_else(|| "?".into()) and renders User #2. Params are always strings; convert to other types at the call site if needed.

router.navigate(cx, path) writes the new path into the router’s signal. It requires cx: &mut App, which is available inside click(move |cx| ...) closures but not inside app() itself — this is why the route dispatch uses match_route() (reactive read, no cx) rather than router.render(cx, ...). The nav-bar buttons and list-item clicks all call navigate to move between routes.

Cascade if let Some pattern for route dispatch

route_content(&router) -> gpui::AnyElement checks each route pattern in turn with if router.match_route(...).is_some() / if let Some(m) = ..., returning the matching view as soon as one hits. If no pattern matches, execution falls through to the final view! { ... 404 ... } expression. Each branch calls .into_any_element() to erase the specific view type into gpui::AnyElement so all branches share a single return type.

for_each for list items

The users list uses for_each(users, |(id, name), _| view! { <li ...> }) to render one <li> per user. Each item clones the router and wires an on:click handler that navigates to /users/{id}, demonstrating how navigation can originate from dynamically generated list elements. for_each is imported explicitly via use vgui::for_each;.

Running

Native:

cargo run -p vgui-router

Web (WASM):

cargo build --target wasm32-unknown-unknown -p vgui-router --release
wasm-bindgen --target web --out-dir examples/router/dist \
    --no-typescript target/wasm32-unknown-unknown/release/router.wasm
python3 scripts/serve_plain.py 8080 examples/router

Dashboard Example

Live Demo

Overview

The dashboard is the capstone vgui application — it combines router, theming, context, forms, overlays, and list rendering into a single multi-module project. It demonstrates:

  • Router-driven view switching between Dashboard, Tasks, and Settings pages.
  • Theme toggle with set_theme() + var() for reactive light/dark theming.
  • Context<ThemeMode> propagated through the tree via <Provider>.
  • Form for adding todos with enter-to-submit inside <form>.
  • dialog() overlay for delete confirmation.
  • <For> (via for_each) for todo list rendering.
  • <Show> for conditional empty state and saved-settings feedback.
  • create_memo for the filtered todo list and completion stats.
  • Multi-module structure (main.rs + theme.rs + views.rs).

Source Code

// --- main.rs ---
#![cfg_attr(target_family = "wasm", no_main)]

mod theme;
mod views;

use gpui::{px, size, App, Bounds, WindowBounds, WindowOptions};
use vgui::prelude::*;

#[cfg(not(target_family = "wasm"))]
use gpui_platform::application;

#[cfg(target_family = "wasm")]
use gpui_platform::single_threaded_web;

#[derive(Clone, PartialEq)]
pub struct Todo {
    pub id: u32,
    pub text: String,
    pub done: bool,
}

fn route_content(
    router: &Router,
    todos: ReadSignal<Vec<Todo>>,
    set_todos: WriteSignal<Vec<Todo>>,
    dialog_open: ReadSignal<bool>,
    set_dialog_open: WriteSignal<bool>,
    name: ReadSignal<String>,
    set_name: WriteSignal<String>,
    email: ReadSignal<String>,
    set_email: WriteSignal<String>,
) -> gpui::AnyElement {
    if router.match_route("/").is_some() {
        return views::dashboard_view(todos).into_any_element();
    }
    if router.match_route("/tasks").is_some() {
        return views::tasks_view(todos, set_todos, dialog_open, set_dialog_open).into_any_element();
    }
    if router.match_route("/settings").is_some() {
        return views::settings_view(name, set_name, email, set_email).into_any_element();
    }
    view! {
        <div class="p-4" style={css!{ flex: 1; }}>
            <h2 class="text-xl font-bold" style={css!{ color: var(--text); }}>
                {"404"}
            </h2>
            <p style={css!{ color: var(--text-muted); }}>
                {"Page not found."}
            </p>
        </div>
    }.into_any_element()
}

fn app() -> impl gpui::IntoElement {
    let router = create_router("/");
    let (theme_mode, set_theme_mode) = create_signal(theme::ThemeMode::Light);
    let (todos, set_todos) = create_signal(vec![
        Todo { id: 0, text: "Learn vgui".into(), done: true },
        Todo { id: 1, text: "Build dashboard".into(), done: false },
        Todo { id: 2, text: "Ship it".into(), done: false },
    ]);
    let (dialog_open, set_dialog_open) = create_signal(false);
    let (name, set_name) = create_signal(String::new());
    let (email, set_email) = create_signal(String::new());

    // Install the theme — reading theme_mode.get() registers a reactive
    // dependency so toggling re-runs render and re-themes everything.
    theme::apply_theme(theme_mode.get());

    let mode = theme_mode.get();
    let set_theme_toggle = set_theme_mode.clone();
    let r_dash = router.clone();
    let r_tasks = router.clone();
    let r_settings = router.clone();

    view! {
        <Provider context={theme::THEME_CTX} value={mode}>
            <div class="flex flex-col" style={css!{
                background: var(--bg);
                color: var(--text);
                height: 100%;
            }}>
                // Top bar
                <div class="flex flex-row items-center justify-between p-3" style={css!{
                    background: var(--surface);
                    border-bottom-width: 1px;
                    border-style: solid;
                    border-color: var(--border);
                }}>
                    <span style={css!{ font-weight: bold; font-size: 18px; color: var(--text); }}>
                        {"vgui Dashboard"}
                    </span>
                    <button
                        style={css!{
                            padding: 6px 12px;
                            background: var(--primary);
                            color: #ffffff;
                            border-width: 0px;
                            border-radius: var(--radius);
                            cursor: pointer;
                            font-size: 12px;
                        }}
                        on:click={click(move |cx| set_theme_toggle.update(cx, |m|
                            *m = match *m {
                                theme::ThemeMode::Light => theme::ThemeMode::Dark,
                                theme::ThemeMode::Dark => theme::ThemeMode::Light,
                            }
                        ))}
                    >
                        {if matches!(mode, theme::ThemeMode::Dark) { "Light" } else { "Dark" }}
                    </button>
                </div>

                // Body: side nav + content
                <div class="flex flex-row" style={css!{ flex: 1; overflow: hidden; }}>
                    // Side nav
                    <div class="flex flex-col gap-1 p-2" style={css!{
                        background: var(--surface);
                        border-right-width: 1px;
                        border-style: solid;
                        border-color: var(--border);
                        width: 140px;
                    }}>
                        <button
                            style={css!{
                                padding: 8px 12px;
                                background: var(--surface);
                                color: var(--text);
                                border-width: 0px;
                                border-radius: var(--radius);
                                cursor: pointer;
                                text-align: left;
                                font-size: 14px;
                            }}
                            on:click={click(move |cx| r_dash.navigate(cx, "/"))}
                        >
                            {"Dashboard"}
                        </button>
                        <button
                            style={css!{
                                padding: 8px 12px;
                                background: var(--surface);
                                color: var(--text);
                                border-width: 0px;
                                border-radius: var(--radius);
                                cursor: pointer;
                                text-align: left;
                                font-size: 14px;
                            }}
                            on:click={click(move |cx| r_tasks.navigate(cx, "/tasks"))}
                        >
                            {"Tasks"}
                        </button>
                        <button
                            style={css!{
                                padding: 8px 12px;
                                background: var(--surface);
                                color: var(--text);
                                border-width: 0px;
                                border-radius: var(--radius);
                                cursor: pointer;
                                text-align: left;
                                font-size: 14px;
                            }}
                            on:click={click(move |cx| r_settings.navigate(cx, "/settings"))}
                        >
                            {"Settings"}
                        </button>
                    </div>

                    // Content area
                    {route_content(
                        &router,
                        todos.clone(),
                        set_todos.clone(),
                        dialog_open.clone(),
                        set_dialog_open.clone(),
                        name.clone(),
                        set_name.clone(),
                        email.clone(),
                        set_email.clone(),
                    )}
                </div>
            </div>
        </Provider>
    }
}

fn run() {
    #[cfg(not(target_family = "wasm"))]
    let gpui_app = application();

    #[cfg(target_family = "wasm")]
    let gpui_app = single_threaded_web();

    let launch = |cx: &mut App| {
        let bounds = Bounds::centered(None, size(px(800.), px(600.0)), cx);
        cx.open_window(
            WindowOptions {
                window_bounds: Some(WindowBounds::Windowed(bounds)),
                ..Default::default()
            },
            |window, cx| vgui::mount(window, cx, app),
        )
        .unwrap();
    };

    #[cfg(not(target_family = "wasm"))]
    gpui_app.run(launch);

    #[cfg(target_family = "wasm")]
    std::mem::forget(gpui_app.run_embedded(launch));
}

#[cfg(not(target_family = "wasm"))]
fn main() {
    run();
}

#[cfg(target_family = "wasm")]
#[wasm_bindgen::prelude::wasm_bindgen(start)]
pub fn start() {
    gpui_platform::web_init();
    vgui::intercept_keyboard_events();
    run();
}

// --- theme.rs ---
use vgui::prelude::*;

/// Light theme built with the `theme!` macro.
fn light_theme() -> Theme {
    theme! {
        --bg: #f5f5f5;
        --surface: #ffffff;
        --primary: #2563ff;
        --text: #111111;
        --text-muted: #666666;
        --border: #dddddd;
        --radius: 8px;
    }
}

/// Dark theme — same variable names, different values.
fn dark_theme() -> Theme {
    theme! {
        --bg: #1a1a2e;
        --surface: #2d2d44;
        --primary: #2563ff;
        --text: #ffffff;
        --text-muted: #aaaaaa;
        --border: #444444;
        --radius: 8px;
    }
}

/// Theme mode propagated through the element tree via `<Provider>`.
#[derive(Clone, Copy, PartialEq)]
pub enum ThemeMode {
    Light,
    Dark,
}

/// The context marker for the theme mode.
pub static THEME_CTX: Context<ThemeMode> = Context::new();

/// Install the active theme based on the mode signal.
pub fn apply_theme(mode: ThemeMode) {
    set_theme(if matches!(mode, ThemeMode::Dark) {
        dark_theme()
    } else {
        light_theme()
    });
}

// --- views.rs ---
use vgui::{dialog, for_each};
use vgui::prelude::*;

use crate::Todo;

/// Dashboard view — summary stat cards + progress bar.
pub fn dashboard_view(todos: ReadSignal<Vec<Todo>>) -> impl gpui::IntoElement {
    let total = todos.get().len();
    let done = todos.get().iter().filter(|t| t.done).count();
    let active = total - done;
    let pct = if total == 0 { 0.0 } else { done as f64 / total as f64 };

    view! {
        <div class="flex flex-col gap-4 p-4" style={css!{ flex: 1; overflow-y: auto; }}>
            <h2 class="text-xl font-bold" style={css!{ color: var(--text); }}>
                {"Dashboard"}
            </h2>

            // Stat cards
            <div class="flex flex-row gap-3">
                {stat_card("Total Tasks", &total.to_string())}
                {stat_card("Active", &active.to_string())}
                {stat_card("Completed", &done.to_string())}
            </div>

            // Progress bar
            <div style={css!{
                background: var(--surface);
                border-width: 1px;
                border-style: solid;
                border-color: var(--border);
                border-radius: var(--radius);
                padding: 16px;
            }}>
                <span style={css!{ color: var(--text-muted); font-size: 12px; }}>
                    {format!("Completion: {:.0}%", pct * 100.0)}
                </span>
                <div style={css!{
                    background: var(--border);
                    border-radius: 4px;
                    height: 8px;
                    margin-top: 8px;
                    overflow: hidden;
                }}>
                    <div style={css!{
                        background: var(--primary);
                        height: 8px;
                    }} class={tw_dynamic(&format!("w-[{}%]", (pct * 100.0) as u32))} />
                </div>
            </div>
        </div>
    }
}

fn stat_card(label: &str, value: &str) -> impl gpui::IntoElement {
    view! {
        <div style={css!{
            background: var(--surface);
            border-width: 1px;
            border-style: solid;
            border-color: var(--border);
            border-radius: var(--radius);
            padding: 16px;
            flex: 1;
        }}>
            <span style={css!{ color: var(--text-muted); font-size: 12px; }}>
                {label.to_string()}
            </span>
            <div style={css!{ color: var(--text); font-size: 24px; font-weight: bold; }}>
                {value.to_string()}
            </div>
        </div>
    }
}

/// Tasks view — todo list with add form, filter buttons, and delete confirmation.
pub fn tasks_view(
    todos: ReadSignal<Vec<Todo>>,
    set_todos: WriteSignal<Vec<Todo>>,
    dialog_open: ReadSignal<bool>,
    set_dialog_open: WriteSignal<bool>,
) -> impl gpui::IntoElement {
    let (new_text, set_new_text) = create_signal(String::new());
    let (filter, set_filter) = create_signal("all".to_string());
    let (next_id, set_next_id) = create_signal(1u32);
    let (delete_id, set_delete_id) = create_signal(0u32);

    let visible = create_memo({
        let todos = todos.clone();
        let filter = filter.clone();
        move || {
            let f = filter.get();
            let all = todos.get();
            match f.as_str() {
                "active" => all.into_iter().filter(|t| !t.done).collect::<Vec<_>>(),
                "done" => all.into_iter().filter(|t| t.done).collect::<Vec<_>>(),
                _ => all,
            }
        }
    });

    let remaining = create_memo({
        let todos = todos.clone();
        move || todos.get().iter().filter(|t| !t.done).count()
    });

    let set_todos_add = set_todos.clone();
    let set_todos_toggle = set_todos.clone();
    let set_todos_delete = set_todos.clone();
    let set_todos_delete_confirm = set_todos.clone();
    let set_filter_all = set_filter.clone();
    let set_filter_active = set_filter.clone();
    let set_filter_done = set_filter.clone();
    let current_filter = filter.get();
    let delete_id_read = delete_id.clone();
    let set_dialog_close = set_dialog_open.clone();
    let set_dialog_confirm = set_dialog_open.clone();
    let new_text_read = new_text.clone();
    let set_new_text_input = set_new_text.clone();
    let set_dialog_cancel = set_dialog_open.clone();

    view! {
        <div class="flex flex-col gap-3 p-4" style={css!{ flex: 1; overflow-y: auto; }}>
            <h2 class="text-xl font-bold" style={css!{ color: var(--text); }}>
                {"Tasks"}
            </h2>

            // Add form
            <form
                on:submit={move |cx: &mut gpui::App| {
                    let text = new_text.get();
                    if !text.is_empty() {
                        let id = next_id.get_with(cx);
                        set_todos_add.update(cx, |todos| {
                            todos.push(Todo { id, text, done: false });
                        });
                        set_next_id.update(cx, |n| *n += 1);
                        set_new_text.set(cx, String::new());
                    }
                }}
            >
                <div class="flex flex-row gap-2">
                    <input
                        type="text"
                        placeholder="Add a task..."
                        value={new_text_read.get()}
                        on:input={move |v: &str, cx: &mut gpui::App| set_new_text_input.set(cx, v.to_string())}
                        style={css!{
                            flex: 1;
                            background: var(--surface);
                            color: var(--text);
                            border-width: 1px;
                            border-style: solid;
                            border-color: var(--border);
                            border-radius: var(--radius);
                            padding: 8px 12px;
                        }}
                    />
                    <input
                        type="submit"
                        value="Add"
                        style={css!{
                            background: var(--primary);
                            color: #ffffff;
                            border-width: 0px;
                            border-radius: var(--radius);
                            padding: 8px 16px;
                            cursor: pointer;
                        }}
                    />
                </div>
            </form>

            // Filter buttons
            <div class="flex flex-row gap-2">
                {filter_button("All", current_filter == "all", move |cx| set_filter_all.set(cx, "all".to_string()))}
                {filter_button("Active", current_filter == "active", move |cx| set_filter_active.set(cx, "active".to_string()))}
                {filter_button("Done", current_filter == "done", move |cx| set_filter_done.set(cx, "done".to_string()))}
            </div>

            // Todo list
            <div class="flex flex-col gap-2" style={css!{ flex: 1; }}>
                <Show when={!visible.get().is_empty()} fallback={view! {
                    <div style={css!{ color: var(--text-muted); text-align: center; padding: 24px; }}>
                        {"No tasks here."}
                    </div>
                }}>
                    {for_each(visible.get(), move |todo: Todo, _| {
                        let set_todos_t = set_todos_toggle.clone();
                        let set_todos_d = set_todos_delete.clone();
                        let set_dialog_o = set_dialog_open.clone();
                        let set_did = set_delete_id.clone();
                        let id = todo.id;
                        let text = todo.text.clone();
                        let done = todo.done;
                        view! {
                            <div class="flex flex-row items-center gap-2" style={css!{
                                background: var(--surface);
                                border-width: 1px;
                                border-style: solid;
                                border-color: var(--border);
                                border-radius: var(--radius);
                                padding: 8px 12px;
                            }}>
                                <button
                                    class={twc!(
                                        "text-white text-xs cursor-pointer",
                                        done.then_some("bg-[#2563ff]"),
                                        (!done).then_some("bg-transparent")
                                    )}
                                    style={css!{
                                        width: 20px;
                                        height: 20px;
                                        border-width: 2px;
                                        border-style: solid;
                                        border-color: var(--border);
                                        border-radius: 4px;
                                    }}
                                    on:click={click(move |cx| set_todos_t.update(cx, |todos| {
                                        if let Some(t) = todos.iter_mut().find(|t| t.id == id) {
                                            t.done = !t.done;
                                        }
                                    }))}
                                >
                                    {if done { "x" } else { "" }}
                                </button>
                                <span class={twc!(
                                    "flex-1 text-sm",
                                    done.then_some("line-through"),
                                    (!done).then_some("no-underline")
                                )} style={css!{ color: var(--text); }}>
                                    {text}
                                </span>
                                <button
                                    style={css!{
                                        background: #dc2626;
                                        color: #ffffff;
                                        border-width: 0px;
                                        border-radius: 4px;
                                        padding: 4px 8px;
                                        font-size: 12px;
                                        cursor: pointer;
                                    }}
                                    on:click={click(move |cx| {
                                        set_did.set(cx, id);
                                        set_dialog_o.set(cx, true);
                                    })}
                                >
                                    {"Delete"}
                                </button>
                            </div>
                        }
                    })}
                </Show>
            </div>

            // Footer
            <div class="flex flex-row justify-between items-center" style={css!{
                border-top-width: 1px;
                border-style: solid;
                border-color: var(--border);
            }}>
                <span style={css!{ color: var(--text-muted); font-size: 12px; }}>
                    {format!("{} items left", remaining.get())}
                </span>
            </div>

            // Delete confirmation dialog
            {dialog(dialog_open.get(), move |cx| set_dialog_close.set(cx, false), view! {
                <div style={css!{
                    background: var(--surface);
                    color: var(--text);
                    padding: 20px;
                    border-radius: var(--radius);
                    max-width: 300px;
                }}>
                    <h3 style={css!{ font-weight: bold; font-size: 16px; margin-bottom: 8px; }}>
                        {"Delete task?"}
                    </h3>
                    <p style={css!{ color: var(--text-muted); font-size: 14px; margin-bottom: 16px; }}>
                        {"This action cannot be undone."}
                    </p>
                    <div class="flex flex-row gap-2 justify-end">
                        <button
                            style={css!{
                                padding: 6px 16px;
                                background: var(--border);
                                color: var(--text);
                                border-width: 0px;
                                border-radius: 4px;
                                cursor: pointer;
                            }}
                            on:click={click(move |cx| set_dialog_cancel.set(cx, false))}
                        >
                            {"Cancel"}
                        </button>
                        <button
                            style={css!{
                                padding: 6px 16px;
                                background: #dc2626;
                                color: #ffffff;
                                border-width: 0px;
                                border-radius: 4px;
                                cursor: pointer;
                            }}
                            on:click={click(move |cx| {
                                let id = delete_id_read.get();
                                set_todos_delete_confirm.update(cx, |todos| {
                                    todos.retain(|t| t.id != id);
                                });
                                set_dialog_confirm.set(cx, false);
                            })}
                        >
                            {"Delete"}
                        </button>
                    </div>
                </div>
            })}
        </div>
    }
}

fn filter_button(
    label: &'static str,
    active: bool,
    on_click: impl Fn(&mut gpui::App) + 'static,
) -> impl gpui::IntoElement {
    let bg = if active { "var(--primary)" } else { "var(--surface)" };
    let color = if active { "#ffffff" } else { "var(--text-muted)" };
    view! {
        <button
            style={css!{
                padding: 4px 12px;
                background: #444444;
                color: #aaaaaa;
                border-width: 1px;
                border-style: solid;
                border-color: var(--border);
                border-radius: 4px;
                cursor: pointer;
                font-size: 12px;
            }}
            class={twc!(active.then_some("bg-[#2563ff] text-white"), (!active).then_some("bg-[#2d2d44] text-[#aaa]"))}
            on:click={click(on_click)}
        >
            {label}
        </button>
    }
}

/// Settings view — form with name/email fields.
pub fn settings_view(
    name: ReadSignal<String>,
    set_name: WriteSignal<String>,
    email: ReadSignal<String>,
    set_email: WriteSignal<String>,
) -> impl gpui::IntoElement {
    let (saved, set_saved) = create_signal(false);
    let set_name_clone = set_name.clone();
    let set_email_clone = set_email.clone();
    let set_saved_reset = set_saved.clone();

    view! {
        <div class="flex flex-col gap-4 p-4" style={css!{ flex: 1; overflow-y: auto; }}>
            <h2 class="text-xl font-bold" style={css!{ color: var(--text); }}>
                {"Settings"}
            </h2>

            <form
                on:submit={move |cx: &mut gpui::App| {
                    set_saved.set(cx, true);
                }}
                on:reset={move |cx: &mut gpui::App| {
                    set_name_clone.set(cx, String::new());
                    set_saved_reset.set(cx, false);
                }}
            >
                <div class="flex flex-col gap-3">
                    <label class="flex flex-col gap-1">
                        <span style={css!{ color: var(--text-muted); font-size: 12px; }}>
                            {"Name"}
                        </span>
                        <input
                            type="text"
                            placeholder="Your name"
                            value={name.get()}
                            on:input={move |v: &str, cx: &mut gpui::App| set_name.set(cx, v.to_string())}
                            style={css!{
                                background: var(--surface);
                                color: var(--text);
                                border-width: 1px;
                                border-style: solid;
                                border-color: var(--border);
                                border-radius: var(--radius);
                                padding: 8px 12px;
                            }}
                        />
                    </label>

                    <label class="flex flex-col gap-1">
                        <span style={css!{ color: var(--text-muted); font-size: 12px; }}>
                            {"Email"}
                        </span>
                        <input
                            type="email"
                            placeholder="you@example.com"
                            value={email.get()}
                            on:input={move |v: &str, cx: &mut gpui::App| set_email.set(cx, v.to_string())}
                            style={css!{
                                background: var(--surface);
                                color: var(--text);
                                border-width: 1px;
                                border-style: solid;
                                border-color: var(--border);
                                border-radius: var(--radius);
                                padding: 8px 12px;
                            }}
                        />
                    </label>

                    <div class="flex flex-row gap-3">
                        <input
                            type="submit"
                            value="Save"
                            style={css!{
                                background: var(--primary);
                                color: #ffffff;
                                border-width: 0px;
                                border-radius: var(--radius);
                                padding: 8px 16px;
                                cursor: pointer;
                            }}
                        />
                        <input
                            type="reset"
                            value="Reset"
                            style={css!{
                                background: var(--surface);
                                color: var(--text);
                                border-width: 1px;
                                border-style: solid;
                                border-color: var(--border);
                                border-radius: var(--radius);
                                padding: 8px 16px;
                                cursor: pointer;
                            }}
                        />
                    </div>
                </div>
            </form>

            <Show when={saved.get()}>
                <div style={css!{
                    background: var(--surface);
                    border-width: 1px;
                    border-style: solid;
                    border-color: var(--border);
                    border-radius: var(--radius);
                    padding: 12px;
                    color: var(--text);
                    font-size: 14px;
                }}>
                    {"Settings saved!"}
                </div>
            </Show>
        </div>
    }
}

Key Concepts

Router + match_route cascade for view dispatch

create_router("/") creates a router backed by a path signal. Because router.render(cx, ...) requires an &App context that is unavailable inside the app() render function, the dashboard dispatches views with a cascade of router.match_route(pattern) calls inside route_content. match_route reads router.path() reactively (no cx needed) and returns Option<RouteMatch>. Each branch returns an impl IntoElement converted via .into_any_element(), falling through to a 404 view. Navigation happens in event handlers with router.navigate(cx, "/tasks"), which updates the path signal and triggers a re-render.

theme! macro with CSS variables

theme.rs defines light_theme() and dark_theme() using the theme! macro, which declares CSS custom properties (--bg, --surface, --primary, --text, --text-muted, --border, --radius). apply_theme(mode) calls set_theme(...) to install the active theme. Because theme_mode.get() is read inside app(), toggling the mode re-runs render, re-installs the theme, and every var(--name) reference in css! blocks picks up the new value — the entire UI re-themes reactively.

Context<T> + <Provider> for theme mode propagation

THEME_CTX: Context<ThemeMode> is a static context marker. The root view wraps the app in <Provider context={THEME_CTX} value={mode}>, making the current theme mode available to any descendant via use_context. This demonstrates how cross-cutting state propagates through the element tree without prop drilling.

Form on:submit for todo creation

The tasks view wraps the text input and submit button in a <form> with an on:submit handler. Pressing Enter inside the text input submits the form automatically, firing the handler which reads new_text, pushes a new Todo, increments the next id, and clears the input. The settings view uses the same pattern with on:submit (to show a saved confirmation) and on:reset (to clear the fields).

dialog() for delete confirmation

Clicking a todo’s Delete button stores the target id in a signal and opens a dialog() overlay. dialog(open, on_close, content) renders the content on a deferred layer with a backdrop and Escape/click-outside dismissal. The dialog’s Confirm button removes the todo by id and closes the dialog; Cancel just closes it.

<For> + <Show> for list rendering

The todo list uses for_each(visible.get(), |todo, _| view!{ ... }) to render each item. The whole list is wrapped in <Show when={!visible.get().is_empty()} fallback={...}> so an empty filtered list displays a “No tasks here.” message instead of nothing. The settings view uses <Show when={saved.get()}> to reveal a saved-confirmation card after submit.

create_memo for derived state

Two memos derive from the todos signal: visible filters the list by the current filter string (all/active/done), and remaining counts incomplete items. The dashboard view computes completion stats (total, done, active, pct) directly from todos.get() and renders a progress bar whose width is a runtime tw_dynamic class. Memos recompute only when their dependencies change, not on every render.

Multi-module project structure

The example is split across three files to show a realistic project layout: main.rs owns the Todo struct, router setup, state signals, theme application, the <Provider> wrapper, and the route_content dispatch; theme.rs owns the theme definitions, ThemeMode enum, the context marker, and apply_theme; views.rs owns the three page views (dashboard_view, tasks_view, settings_view) and helpers (stat_card, filter_button). Modules are declared with mod theme; / mod views; and referenced as theme::THEME_CTX, views::dashboard_view, etc.

Running

Native:

cargo run -p vgui-dashboard

Web (WASM):

cargo build --target wasm32-unknown-unknown -p vgui-dashboard --release
wasm-bindgen --target web --out-dir examples/dashboard/dist \
    --no-typescript target/wasm32-unknown-unknown/release/dashboard.wasm
python3 scripts/serve_plain.py 8080 examples/dashboard

Store Example

Live Demo

Overview

The shopping cart demonstrates create_store — vgui’s SolidJS-inspired reactive store for aggregate state. It shows:

  • create_store wrapping a single CartState struct (items + discount + tax).
  • Store::select for fine-grained selectors — each slice gets its own ReadSignal that only updates when that slice changes.
  • create_memo layered on top of selectors for derived computations (discount amount, after-discount, tax, total).
  • SetStore::update for in-place partial mutations (qty, discount, tax).
  • Store::with to borrow the state without cloning.
  • <For> with fallback for list rendering.
  • <input type="range"> sliders driving store fields.

Source Code

#![cfg_attr(target_family = "wasm", no_main)]

use gpui::{px, size, App, Bounds, WindowBounds, WindowOptions};
use vgui::prelude::*;

#[cfg(not(target_family = "wasm"))]
use gpui_platform::application;

#[cfg(target_family = "wasm")]
use gpui_platform::single_threaded_web;

// ---------------------------------------------------------------------------
// State — a single aggregate struct held in a `create_store`.
//
// `CartState` does NOT derive `PartialEq`. The store always notifies on write;
// fine-grained filtering is delegated to `Store::select`, whose slice type
// `U` must implement `PartialEq`.
// ---------------------------------------------------------------------------

#[derive(Clone)]
struct Product {
    id: u32,
    name: &'static str,
    price: f64,
}

#[derive(Clone)]
struct CartItem {
    product: Product,
    qty: u32,
}

#[derive(Clone)]
struct CartState {
    items: Vec<CartItem>,
    discount_pct: f64, // 0.0 – 100.0
    tax_pct: f64,      // 0.0 – 100.0
}

impl CartState {
    fn new() -> Self {
        Self {
            items: vec![
                CartItem {
                    product: Product { id: 0, name: "Rust Book", price: 39.99 },
                    qty: 1,
                },
                CartItem {
                    product: Product { id: 1, name: "Mechanical Keyboard", price: 129.00 },
                    qty: 1,
                },
                CartItem {
                    product: Product { id: 2, name: "Coffee Mug", price: 12.50 },
                    qty: 2,
                },
            ],
            discount_pct: 0.0,
            tax_pct: 8.0,
        }
    }

    fn subtotal(&self) -> f64 {
        self.items.iter().map(|i| i.product.price * i.qty as f64).sum()
    }
}

// ---------------------------------------------------------------------------
// UI helpers
// ---------------------------------------------------------------------------

fn money(v: f64) -> String {
    format!("${:.2}", v)
}

fn cart_row(
    name: &str,
    price: f64,
    qty: u32,
    on_inc: impl Fn(&mut gpui::App) + 'static,
    on_dec: impl Fn(&mut gpui::App) + 'static,
    on_remove: impl Fn(&mut gpui::App) + 'static,
) -> impl gpui::IntoElement {
    view! {
        <div class="flex flex-row items-center gap-3 p-2 rounded bg-[#2a2a3a]">
            <span class="flex-1 text-sm text-white">{name.to_string()}</span>
            <span class="text-sm text-[#aaa] w-20 text-right">{money(price)}</span>
            <div class="flex flex-row items-center gap-1">
                <button
                    class="w-6 h-6 rounded bg-[#444] text-white text-sm hover:bg-[#555]"
                    on:click={click(on_dec)}
                >
                    {"-"}
                </button>
                <span class="w-8 text-center text-white text-sm">{qty.to_string()}</span>
                <button
                    class="w-6 h-6 rounded bg-[#444] text-white text-sm hover:bg-[#555]"
                    on:click={click(on_inc)}
                >
                    {"+"}
                </button>
            </div>
            <button
                class="px-2 py-1 rounded bg-[#dc2626] text-white text-xs hover:bg-[#b91c1c]"
                on:click={click(on_remove)}
            >
                {"Remove"}
            </button>
        </div>
    }
}

fn slider_row(
    label: &str,
    value: f64,
    on_change: impl Fn(f64, &mut gpui::App) + 'static,
) -> impl gpui::IntoElement {
    view! {
        <div class="flex flex-row items-center gap-3">
            <span class="text-sm text-[#ccc] w-24">{label.to_string()}</span>
            <input
                type="range"
                min={0.0f64}
                max={100.0f64}
                step={1.0f64}
                value={value}
                on:change={on_change}
            />
            <span class="text-sm text-white w-12 text-right">{format!("{:.0}%", value)}</span>
        </div>
    }
}

fn summary_row(label: &str, value: &str, highlight: bool) -> impl gpui::IntoElement {
    let class = if highlight {
        "flex flex-row justify-between text-sm font-bold text-white border-t border-[#444] pt-2 mt-2"
    } else {
        "flex flex-row justify-between text-sm text-[#ccc]"
    };
    view! {
        <div class={class}>
            <span>{label.to_string()}</span>
            <span>{value.to_string()}</span>
        </div>
    }
}

// ---------------------------------------------------------------------------
// App
// ---------------------------------------------------------------------------

fn app() -> impl gpui::IntoElement {
    let (cart, set_cart) = create_store(CartState::new());

    // Fine-grained selectors — each derives a ReadSignal from a single slice.
    // Updating `discount_pct` does NOT cause `item_count` or `subtotal` to
    // re-render; updating a quantity does NOT re-render `discount_pct`.
    let item_count = cart.select(|s| s.items.len());
    let subtotal = cart.select(|s| s.subtotal());
    let discount_pct = cart.select(|s| s.discount_pct);
    let tax_pct = cart.select(|s| s.tax_pct);

    // Derived values built on top of selectors — these are memos that depend
    // on multiple selector signals. They recompute only when their inputs
    // change, thanks to the fine-grained selectors below them.
    let discount_amount = create_memo({
        let subtotal = subtotal.clone();
        let discount_pct = discount_pct.clone();
        move || subtotal.get() * discount_pct.get() / 100.0
    });

    let after_discount = create_memo({
        let subtotal = subtotal.clone();
        let discount_amount = discount_amount.clone();
        move || subtotal.get() - discount_amount.get()
    });

    let tax_amount = create_memo({
        let after_discount = after_discount.clone();
        let tax_pct = tax_pct.clone();
        move || after_discount.get() * tax_pct.get() / 100.0
    });

    let total = create_memo({
        let after_discount = after_discount.clone();
        let tax_amount = tax_amount.clone();
        move || after_discount.get() + tax_amount.get()
    });

    // Read current values for this render.
    let items = cart.with(|s| s.items.clone());
    let cur_discount = discount_pct.get();
    let cur_tax = tax_pct.get();
    let cur_subtotal = subtotal.get();
    let cur_discount_amt = discount_amount.get();
    let cur_after = after_discount.get();
    let cur_tax_amt = tax_amount.get();
    let cur_total = total.get();
    let cur_count = item_count.get();

    let set_cart_discount = set_cart.clone();
    let set_cart_tax = set_cart.clone();
    let set_cart_clear = set_cart.clone();

    view! {
        <div class="flex flex-col gap-4 p-6 bg-[#1a1a2e] w-[520px] h-[700px] text-white overflow-hidden">
            <span class="text-xl font-bold text-center">{"Shopping Cart (create_store)"}</span>

            // Item count — driven by `item_count` selector only.
            <span class="text-sm text-[#aaa] text-center">
                {format!("{} items in cart", cur_count)}
            </span>

            // Cart items
            <div class="flex flex-col gap-2 flex-1 overflow-y-auto">
                <For each={items.clone()} fallback={view! {
                    <div class="text-center text-[#888] py-8 text-sm">
                        {"Cart is empty."}
                    </div>
                }}>
                    {move |item: CartItem, _i: usize| {
                        let id = item.product.id;
                        let name = item.product.name;
                        let price = item.product.price;
                        let qty = item.qty;
                        let sc = set_cart.clone();
                        let sc2 = set_cart.clone();
                        let sc3 = set_cart.clone();
                        cart_row(
                            name,
                            price,
                            qty,
                            move |cx| sc.update(cx, |s| {
                                if let Some(it) = s.items.iter_mut().find(|i| i.product.id == id) {
                                    it.qty += 1;
                                }
                            }),
                            move |cx| sc2.update(cx, |s| {
                                if let Some(it) = s.items.iter_mut().find(|i| i.product.id == id) {
                                    if it.qty > 1 { it.qty -= 1; }
                                }
                            }),
                            move |cx| sc3.update(cx, |s| {
                                s.items.retain(|i| i.product.id != id);
                            }),
                        )
                    }}
                </For>
            </div>

            // Sliders for discount and tax — each updates one field of the
            // store. Only the affected selectors and their dependents
            // recompute; the item list is unaffected.
            <div class="flex flex-col gap-2 p-3 rounded bg-[#252535]">
                <span class="text-sm font-bold text-[#ccc]">{"Pricing Controls"}</span>
                {slider_row("Discount", cur_discount, move |v, cx| {
                    set_cart_discount.update(cx, |s| s.discount_pct = v);
                })}
                {slider_row("Tax", cur_tax, move |v, cx| {
                    set_cart_tax.update(cx, |s| s.tax_pct = v);
                })}
            </div>

            // Order summary — each line reads a different selector / memo.
            // Changing the discount only re-runs `discount_amount`,
            // `after_discount`, `tax_amount`, and `total` — not `item_count`
            // or the item list itself.
            <div class="flex flex-col gap-1 p-3 rounded bg-[#252535]">
                <span class="text-sm font-bold text-[#ccc] mb-1">{"Order Summary"}</span>
                {summary_row("Subtotal", &money(cur_subtotal), false)}
                {summary_row("Discount", &format!("-{}", money(cur_discount_amt)), false)}
                {summary_row("After Discount", &money(cur_after), false)}
                {summary_row("Tax", &format!("+{}", money(cur_tax_amt)), false)}
                {summary_row("Total", &money(cur_total), true)}
            </div>

            <button
                class="p-2 rounded bg-[#dc2626] text-white text-sm hover:bg-[#b91c1c]"
                on:click={click(move |cx| {
                    set_cart_clear.update(cx, |s| s.items.clear());
                })}
            >
                {"Clear Cart"}
            </button>
        </div>
    }
}

fn run() {
    #[cfg(not(target_family = "wasm"))]
    let gpui_app = application();

    #[cfg(target_family = "wasm")]
    let gpui_app = single_threaded_web();

    let launch = |cx: &mut App| {
        let bounds = Bounds::centered(None, size(px(520.), px(700.0)), cx);
        cx.open_window(
            WindowOptions {
                window_bounds: Some(WindowBounds::Windowed(bounds)),
                ..Default::default()
            },
            |window, cx| vgui::mount(window, cx, app),
        )
        .unwrap();
    };

    #[cfg(not(target_family = "wasm"))]
    gpui_app.run(launch);

    #[cfg(target_family = "wasm")]
    std::mem::forget(gpui_app.run_embedded(launch));
}

#[cfg(not(target_family = "wasm"))]
fn main() {
    run();
}

#[cfg(target_family = "wasm")]
#[wasm_bindgen::prelude::wasm_bindgen(start)]
pub fn start() {
    gpui_platform::web_init();
    vgui::intercept_keyboard_events();
    run();
}

Key Concepts

create_store for aggregate state

The entire cart state — items, discount percentage, tax percentage — lives in a single CartState struct held by one create_store call:

#![allow(unused)]
fn main() {
let (cart, set_cart) = create_store(CartState::new());
}

CartState derives Clone but not PartialEq. Unlike create_signal, the store does not compare old vs new state on write — writes always notify. This is intentional: the store delegates fine-grained filtering to select.

Store::select — fine-grained lens selectors

Each field of the store gets its own selector, producing an independent ReadSignal<U>:

#![allow(unused)]
fn main() {
let item_count = cart.select(|s| s.items.len());
let subtotal = cart.select(|s| s.subtotal());
let discount_pct = cart.select(|s| s.discount_pct);
let tax_pct = cart.select(|s| s.tax_pct);
}

The closure is a lens — it reads one slice of the state. The selector recomputes whenever the store changes (any write triggers a notification), but only propagates to its own dependents when the selected value differs (U: PartialEq). This is the Rust-idiomatic equivalent of SolidJS path-level tracking: instead of JS Proxy interception, the user explicitly declares which slice they care about.

Concrete effect in this example: dragging the Discount slider updates discount_pct only. The item_count and subtotal selectors recompute but their values are unchanged, so they do not propagate. The item list does not re-render. Only discount_amountafter_discounttax_amounttotal recompute and propagate downstream.

create_memo layered on selectors

Derived computations are memos that read selector signals:

#![allow(unused)]
fn main() {
let discount_amount = create_memo({
    let subtotal = subtotal.clone();
    let discount_pct = discount_pct.clone();
    move || subtotal.get() * discount_pct.get() / 100.0
});
}

Because subtotal and discount_pct are fine-grained selectors (not the whole store), this memo only recomputes when one of those two slices changes. Updating tax_pct does not recompute discount_amount.

SetStore::update for partial mutations

Each button handler mutates one field of the store in place:

#![allow(unused)]
fn main() {
// Increment quantity
set_cart.update(cx, |s| {
    if let Some(it) = s.items.iter_mut().find(|i| i.product.id == id) {
        it.qty += 1;
    }
});

// Set discount from slider
set_cart.update(cx, |s| s.discount_pct = v);
}

update always notifies after the closure returns. Downstream select signals filter out unchanged slices, so only the affected parts of the UI re-render.

Store::with for zero-clone reads

When the render needs the full items list (for <For>), with borrows the state through a closure without cloning the entire struct:

#![allow(unused)]
fn main() {
let items = cart.with(|s| s.items.clone());
}

This tracks the entire store as a dependency — any write triggers a re-render. For fine-grained tracking, use select instead.

Store vs. signal: when to use which

This example could be built with three separate create_signal calls (items, discount_pct, tax_pct). The store approach is preferable when:

  • The state fields are conceptually one unit (a cart, a form, a user profile).
  • You want partial updates without managing N separate WriteSignal handles.
  • The state type cannot or should not implement PartialEq (e.g. contains Vecs or large nested structs).

Use create_signal when you have a single flat value and want automatic equality-skipping on write.

Running

Native:

cargo run -p vgui-store

Web (WASM):

cargo build --target wasm32-unknown-unknown -p vgui-store --release
wasm-bindgen --target web --out-dir examples/store/dist \
    --no-typescript target/wasm32-unknown-unknown/release/store.wasm
python3 scripts/serve_plain.py 8080 examples/store

useInterval Example

Live Demo

Source Code

#![cfg_attr(target_family = "wasm", no_main)]

use std::cell::RefCell;
use std::time::Duration;

use gpui::{px, size, App, Bounds, WindowBounds, WindowOptions};
use vgui::prelude::*;

#[cfg(not(target_family = "wasm"))]
use gpui_platform::application;

#[cfg(target_family = "wasm")]
use gpui_platform::single_threaded_web;

fn app() -> impl gpui::IntoElement {
    // ── use_interval demo ───────────────────────────────────────────
    // A counter that ticks every `delay` milliseconds. The delay is
    // reactively controlled — changing it re-runs the effect inside
    // `use_interval`, cancelling the old timer and starting a new one.
    // Setting delay to 0 pauses the interval entirely.
    let (count, set_count) = create_signal(0i32);
    let (delay, set_delay) = create_signal(1000u64);
    let (running, set_running) = create_signal(true);

    // When `running` is false, we feed 0 into the delay signal so the
    // interval pauses. When true, we use the user-selected delay.
    let effective_delay = create_memo({
        let delay = delay.clone();
        let running = running.clone();
        move || if running.get() { delay.get() } else { 0 }
    });

    let set_count_reset = set_count.clone();
    use_interval(
        move |cx| set_count.update(cx, |n| *n += 1),
        effective_delay,
    );
    let set_running_start = set_running.clone();
    let set_running_pause = set_running.clone();

    view! {
        <div class="flex flex-col gap-4 p-6 bg-[#1a1a2e] w-full h-full text-white justify-center items-center">
            <h2 class="text-lg font-bold">{"useInterval Demo"}</h2>

            // ── Timer display ────────────────────────────────────────
            <div class="text-4xl font-mono tabular-nums">
                {format!("{}s", count.get() / 10)}
            </div>
            <div class="text-sm text-[#aaa]">
                {format!("ticks: {}", count.get())}
            </div>

            // ── Start / Pause ────────────────────────────────────────
            <div class="flex gap-2">
                <button
                    class={twc!(
                        "px-4 py-2 rounded font-medium",
                        running.get().then_some("bg-[#3b82f6] hover:bg-[#2563eb]"),
                        (!running.get()).then_some("bg-[#666] hover:bg-[#555]")
                    )}
                    on:click={click(move |cx| set_running_start.set(cx, true))}
                >
                    {"Start"}
                </button>
                <button
                    class={twc!(
                        "px-4 py-2 rounded font-medium",
                        (!running.get()).then_some("bg-[#ef4444] hover:bg-[#dc2626]"),
                        running.get().then_some("bg-[#666] hover:bg-[#555]")
                    )}
                    on:click={click(move |cx| set_running_pause.set(cx, false))}
                >
                    {"Pause"}
                </button>
                <button
                    class="px-4 py-2 rounded font-medium bg-[#6366f1] hover:bg-[#4f46e5]"
                    on:click={click(move |cx| {
                        set_count_reset.set(cx, 0);
                    })}
                >
                    {"Reset"}
                </button>
            </div>

            // ── Delay selector ───────────────────────────────────────
            <div class="flex flex-col gap-2 items-center">
                <span class="text-sm text-[#aaa]">{format!("Delay: {} ms", delay.get())}</span>
                <div class="flex gap-2">
                    {vgui::for_each([100u64, 500, 1000, 2000], move |d, _| {
                        let set_delay = set_delay.clone();
                        let delay = delay.clone();
                        view! {
                            <button
                                class={twc!(
                                    "px-3 py-1.5 rounded text-sm",
                                    (delay.get() == d).then_some("bg-[#10b981] text-white"),
                                    (delay.get() != d).then_some("bg-[#333] hover:bg-[#444] text-[#ccc]")
                                )}
                                on:click={click(move |cx| set_delay.set(cx, d))}
                            >
                                {format!("{}ms", d)}
                            </button>
                        }
                    })}
                </div>
            </div>

            // ── set_interval demo (independent) ──────────────────────
            <Blinker />
        </div>
    }
}

/// A self-contained component that uses the low-level `set_interval`
/// directly (without `use_interval`). The interval is created once on
/// first render and cancelled when the scope is disposed via
/// `on_cleanup`.
#[allow(non_snake_case)]
fn Blinker() -> impl gpui::IntoElement {
    let (on, set_on) = create_signal(false);

    // `set_interval` returns an `IntervalHandle` that owns the gpui task.
    // Dropping the handle cancels the interval (like JS `clearInterval`).
    // Here we keep the handle alive by moving it into `on_cleanup`, which
    // runs — and drops it — when this scope is disposed.
    let handle = set_interval(
        move |cx| set_on.update(cx, |v| *v = !*v),
        Duration::from_millis(600),
    );
    let handle = RefCell::new(Some(handle));
    on_cleanup(move || {
        *handle.borrow_mut() = None;
    });

    view! {
        <div class="flex items-center gap-2 mt-2">
            <span class="text-sm text-[#aaa]">{"set_interval blink:"}</span>
            <div class={twc!(
                "w-4 h-4 rounded-full transition-colors",
                on.get().then_some("bg-[#f59e0b]"),
                (!on.get()).then_some("bg-[#333]")
            )} />
        </div>
    }
}

fn run() {
    #[cfg(not(target_family = "wasm"))]
    let gpui_app = application();

    #[cfg(target_family = "wasm")]
    let gpui_app = single_threaded_web();

    let launch = |cx: &mut App| {
        let bounds = Bounds::centered(None, size(px(460.), px(420.0)), cx);
        cx.open_window(
            WindowOptions {
                window_bounds: Some(WindowBounds::Windowed(bounds)),
                ..Default::default()
            },
            |window, cx| vgui::mount(window, cx, app),
        )
        .unwrap();
    };

    #[cfg(not(target_family = "wasm"))]
    gpui_app.run(launch);

    #[cfg(target_family = "wasm")]
    std::mem::forget(gpui_app.run_embedded(launch));
}

#[cfg(not(target_family = "wasm"))]
fn main() {
    run();
}

#[cfg(target_family = "wasm")]
#[wasm_bindgen::prelude::wasm_bindgen(start)]
pub fn start() {
    gpui_platform::web_init();
    vgui::intercept_keyboard_events();
    run();
}

Key Concepts

set_interval — low-level repeating timer

Analogous to JavaScript’s setInterval. Spawns a gpui foreground task that calls the callback every duration. The callback receives &mut gpui::AsyncApp so it can update signals and entities across await points. The returned IntervalHandle owns the underlying task; dropping it cancels the interval immediately (equivalent to clearInterval).

use_interval — reactive hook

Combines create_effect + on_cleanup to provide a declarative interval that responds to reactive state:

  • Takes a ReadSignal<u64> delay in milliseconds. When the value is 0 the interval is paused.
  • When the delay changes, the effect re-runs: the previous interval is cancelled and a new one starts with the updated period.
  • When the enclosing scope is disposed (e.g. a <Switch> branch goes inactive), on_cleanup cancels the interval automatically.

Pattern: pausing via memo

A common pattern is to derive the effective delay from multiple signals — e.g. a running boolean and a user-selected delay — using create_memo:

#![allow(unused)]
fn main() {
let effective_delay = create_memo(move || {
    if running.get() { delay.get() } else { 0 }
});
use_interval(move |cx| set_count.update(cx, |n| *n += 1), effective_delay);
}

Setting running to false feeds 0 into use_interval, which pauses the interval without dropping the hook.

Running

Native

cargo run -p vgui-use-interval

Web (WASM)

cargo build --target wasm32-unknown-unknown -p vgui-use-interval --release
wasm-bindgen --target web --out-dir examples/use-interval/dist \
    --no-typescript target/wasm32-unknown-unknown/release/use-interval.wasm
python3 scripts/serve_plain.py 8080 examples/use-interval

Canvas Animation Example

Live Demo

Source Code

#![cfg_attr(target_family = "wasm", no_main)]

use gpui::{px, size, App, Bounds, WindowBounds, WindowOptions};
use vgui::prelude::*;

#[cfg(not(target_family = "wasm"))]
use gpui_platform::application;

#[cfg(target_family = "wasm")]
use gpui_platform::single_threaded_web;

// Canvas dimensions (logical pixels).
const W: f32 = 480.0;
const H: f32 = 360.0;

const PALETTE: [&str; 7] = [
    "#ef4444", "#3b82f6", "#10b981", "#f59e0b",
    "#8b5cf6", "#ec4899", "#06b6d4",
];

#[derive(Clone, PartialEq)]
struct Particle {
    x: f32,
    y: f32,
    vx: f32,
    vy: f32,
    radius: f32,
    color_idx: usize,
}

fn init_particles(n: usize) -> Vec<Particle> {
    (0..n)
        .map(|i| {
            let angle = (i as f32) * 2.399;
            let speed = 1.2 + (i as f32 % 3.0) * 0.6;
            Particle {
                x: W * 0.5 + ((i as f32 * 41.0) % (W * 0.5)),
                y: H * 0.5 + ((i as f32 * 67.0) % (H * 0.5)),
                vx: angle.cos() * speed,
                vy: angle.sin() * speed,
                radius: 5.0 + (i as f32 % 3.0) * 2.0,
                color_idx: i % PALETTE.len(),
            }
        })
        .collect()
}

fn app() -> impl gpui::IntoElement {
    let (particles, set_particles) = create_signal(init_particles(12));
    let (running, set_running) = create_signal(true);
    let (speed, set_speed) = create_signal(2u64);
    let (count, set_count) = create_signal(12u32);

    // Derive the effective interval delay from `running` and `speed`.
    // 0 pauses the interval; otherwise ~50ms / speed gives 20–100 fps.
    let delay = create_memo({
        let running = running.clone();
        let speed = speed.clone();
        move || if running.get() { 50u64 / speed.get().max(1) } else { 0 }
    });

    // Clone before moving into use_interval — needed by Reset and
    // particle-count buttons later.
    let set_particles_reset = set_particles.clone();

    // Animation loop: update particle positions on every tick.
    use_interval(
        move |cx| {
            set_particles.update(cx, |ps| {
                for p in ps.iter_mut() {
                    p.x += p.vx;
                    p.y += p.vy;
                    if p.x < p.radius {
                        p.x = p.radius;
                        p.vx = p.vx.abs();
                    }
                    if p.x > W - p.radius {
                        p.x = W - p.radius;
                        p.vx = -p.vx.abs();
                    }
                    if p.y < p.radius {
                        p.y = p.radius;
                        p.vy = p.vy.abs();
                    }
                    if p.y > H - p.radius {
                        p.y = H - p.radius;
                        p.vy = -p.vy.abs();
                    }
                }
            });
        },
        delay,
    );

    // Read signal values during render so the canvas paint closure
    // captures the current snapshot.  The signal reads are tracked,
    // so any change triggers a re-render and a fresh canvas paint.
    let snapshot = particles.get();
    let is_running = running.get();
    let cur_count = count.get();

    // Clones for closures that outlive this render call.
    let set_running_play = set_running.clone();
    let set_running_pause = set_running.clone();

    view! {
        <div class="flex flex-col gap-4 p-6 bg-[#0f0f1e] w-full h-full text-white rounded">
            <h2 class="text-lg font-bold">{"Particle Animation"}</h2>
            <span class="text-sm text-[#888] -mt-2">
                {"use_interval + canvas — reactive timer-driven rendering"}
            </span>

            // ── Canvas ──────────────────────────────────────────────
            <canvas
                class="w-[480px] h-[360px] bg-[#16162a] rounded"
                paint={move |ctx: &mut Context2D| {
                    // Constellation lines: connect nearby particles.
                    for i in 0..snapshot.len() {
                        for j in (i + 1)..snapshot.len() {
                            let dx = snapshot[i].x - snapshot[j].x;
                            let dy = snapshot[i].y - snapshot[j].y;
                            let dist = (dx * dx + dy * dy).sqrt();
                            if dist < 110.0 {
                                let alpha = (1.0 - dist / 110.0) * 0.4;
                                ctx.set_global_alpha(alpha);
                                ctx.set_stroke_style(color("#5588cc"));
                                ctx.set_line_width(1.0);
                                ctx.begin_path();
                                ctx.move_to(snapshot[i].x, snapshot[i].y);
                                ctx.line_to(snapshot[j].x, snapshot[j].y);
                                ctx.stroke();
                            }
                        }
                    }
                    ctx.set_global_alpha(1.0);

                    // Particles.
                    for p in &snapshot {
                        ctx.set_fill_style(color(PALETTE[p.color_idx]));
                        ctx.begin_path();
                        ctx.arc(p.x, p.y, p.radius, 0.0, std::f32::consts::TAU, false);
                        ctx.fill();
                    }
                }}
            />

            // ── Play / Pause / Reset ────────────────────────────────
            <div class="flex gap-2">
                <button
                    class={twc!(
                        "px-4 py-2 rounded font-medium",
                        is_running.then_some("bg-[#3b82f6] hover:bg-[#2563eb]"),
                        (!is_running).then_some("bg-[#444] hover:bg-[#555]")
                    )}
                    on:click={click(move |cx| set_running_play.set(cx, true))}
                >
                    {"Play"}
                </button>
                <button
                    class={twc!(
                        "px-4 py-2 rounded font-medium",
                        (!is_running).then_some("bg-[#ef4444] hover:bg-[#dc2626]"),
                        is_running.then_some("bg-[#444] hover:bg-[#555]")
                    )}
                    on:click={click(move |cx| set_running_pause.set(cx, false))}
                >
                    {"Pause"}
                </button>
                <button
                    class="px-4 py-2 rounded font-medium bg-[#6366f1] hover:bg-[#4f46e5]"
                    on:click={click({
                        let sp = set_particles_reset.clone();
                        move |cx| sp.set(cx, init_particles(cur_count as usize))
                    })}
                >
                    {"Reset"}
                </button>
            </div>

            // ── Speed selector ──────────────────────────────────────
            <div class="flex gap-2 items-center">
                <span class="text-sm text-[#888] w-16">{"Speed"}</span>
                {vgui::for_each([1u64, 2, 3, 5], move |s, _| {
                    let set_speed = set_speed.clone();
                    let speed = speed.clone();
                    view! {
                        <button
                            class={twc!(
                                "px-3 py-1.5 rounded text-sm",
                                (speed.get() == s).then_some("bg-[#10b981] text-white"),
                                (speed.get() != s).then_some("bg-[#333] hover:bg-[#444] text-[#ccc]")
                            )}
                            on:click={click(move |cx| set_speed.set(cx, s))}
                        >
                            {format!("{}x", s)}
                        </button>
                    }
                })}
            </div>

            // ── Particle count selector ─────────────────────────────
            <div class="flex gap-2 items-center">
                <span class="text-sm text-[#888] w-16">{"Particles"}</span>
                {vgui::for_each([6u32, 12, 48, 96], move |n, _| {
                    let set_count = set_count.clone();
                    let count = count.clone();
                    let set_particles = set_particles_reset.clone();
                    view! {
                        <button
                            class={twc!(
                                "px-3 py-1.5 rounded text-sm",
                                (count.get() == n).then_some("bg-[#10b981] text-white"),
                                (count.get() != n).then_some("bg-[#333] hover:bg-[#444] text-[#ccc]")
                            )}
                            on:click={click(move |cx| {
                                set_count.set(cx, n);
                                set_particles.set(cx, init_particles(n as usize));
                            })}
                        >
                            {format!("{}", n)}
                        </button>
                    }
                })}
            </div>
        </div>
    }
}

fn run() {
    #[cfg(not(target_family = "wasm"))]
    let gpui_app = application();

    #[cfg(target_family = "wasm")]
    let gpui_app = single_threaded_web();

    let launch = |cx: &mut App| {
        let bounds = Bounds::centered(None, size(px(540.), px(600.0)), cx);
        cx.open_window(
            WindowOptions {
                window_bounds: Some(WindowBounds::Windowed(bounds)),
                ..Default::default()
            },
            |window, cx| vgui::mount(window, cx, app),
        )
        .unwrap();
    };

    #[cfg(not(target_family = "wasm"))]
    gpui_app.run(launch);

    #[cfg(target_family = "wasm")]
    std::mem::forget(gpui_app.run_embedded(launch));
}

#[cfg(not(target_family = "wasm"))]
fn main() {
    run();
}

#[cfg(target_family = "wasm")]
#[wasm_bindgen::prelude::wasm_bindgen(start)]
pub fn start() {
    gpui_platform::web_init();
    vgui::intercept_keyboard_events();
    run();
}

Key Concepts

Combining use_interval with <canvas>

The animation loop is driven by use_interval, which ticks a reactive timer and updates a Vec<Particle> signal on every tick. The <canvas> element’s paint closure reads the particle snapshot during render, so each signal update triggers a re-render and a fresh canvas paint.

Reactive speed control

The interval delay is derived from a speed signal via create_memo:

#![allow(unused)]
fn main() {
let delay = create_memo(move || {
    if running.get() { 50 / speed.get().max(1) } else { 0 }
});
use_interval(move |cx| set_particles.update(cx, |ps| { /* ... */ }), delay);
}

Changing the speed or pausing re-runs the effect inside use_interval, cancelling the old timer and starting a new one. Setting delay to 0 pauses the animation entirely.

Particle physics

Each tick updates particle positions and bounces them off the canvas boundaries. The paint closure also draws constellation lines between nearby particles, with opacity proportional to distance.

Running

Native

cargo run -p vgui-canvas-animation

Web (WASM)

cargo build --target wasm32-unknown-unknown -p vgui-canvas-animation --release
wasm-bindgen --target web --out-dir examples/canvas-animation/dist \
    --no-typescript target/wasm32-unknown-unknown/release/canvas-animation.wasm
python3 scripts/serve_plain.py 8080 examples/canvas-animation

Keyboard Events Example

Live Demo

Source Code

#![cfg_attr(target_family = "wasm", no_main)]

use gpui::{px, size, App, Bounds, WindowBounds, WindowOptions};
use vgui::prelude::*;
use vgui::show_when;

#[cfg(not(target_family = "wasm"))]
use gpui_platform::application;

#[cfg(target_family = "wasm")]
use gpui_platform::single_threaded_web;

/// A command entry in the palette.
struct Command {
    name: &'static str,
    shortcut: &'static str,
}

const COMMANDS: &[Command] = &[
    Command { name: "New File",        shortcut: "Ctrl+N" },
    Command { name: "Open File",       shortcut: "Ctrl+O" },
    Command { name: "Save",            shortcut: "Ctrl+S" },
    Command { name: "Save As",         shortcut: "Ctrl+Shift+S" },
    Command { name: "Find",            shortcut: "Ctrl+F" },
    Command { name: "Replace",         shortcut: "Ctrl+H" },
    Command { name: "Toggle Terminal", shortcut: "Ctrl+`" },
    Command { name: "Format Document", shortcut: "Shift+Alt+F" },
    Command { name: "Command Palette", shortcut: "Ctrl+K" },
    Command { name: "Close Tab",       shortcut: "Ctrl+W" },
    Command { name: "Undo",            shortcut: "Ctrl+Z" },
    Command { name: "Redo",            shortcut: "Ctrl+Y" },
];

fn app() -> impl gpui::IntoElement {
    // ── State ──────────────────────────────────────────────────────
    let (palette_open, set_palette) = create_signal(false);
    let (selected, set_selected) = create_signal(0usize);
    let (last_key, set_last_key) = create_signal(String::new());
    let (last_action, set_last_action) = create_signal(String::from("Press ? for help"));
    let (help_open, set_help) = create_signal(false);

    // ── Global keyboard shortcuts (use_key_down) ───────────────────
    // These fire regardless of which element has focus. They form the
    // application-level keyboard layer: palette toggle, help, and
    // navigation within the palette.
    let set_palette_open = set_palette.clone();
    let set_palette_close = set_palette.clone();
    let set_help_open = set_help.clone();
    let set_help_close = set_help.clone();
    let set_sel = set_selected.clone();
    let set_act = set_last_action.clone();
    let set_key = set_last_key.clone();
    let palette_open_r = palette_open.clone();
    let selected_r = selected.clone();

    use_key_down(move |e: &KeyboardEvent, _w, cx| {
        // Live key inspector — record every key press.
        set_key.set(cx, format_key_event(e));

        // Ctrl+K toggles the command palette.
        if e.ctrl_key && e.key == "k" {
            set_palette_open.update(cx, |v| *v = !*v);
            set_sel.set(cx, 0);
            return; // don't process further
        }

        // Escape closes whatever is open.
        if e.key == "Escape" {
            set_palette_close.set(cx, false);
            set_help_close.set(cx, false);
            return;
        }

        // "?" toggles help (Shift+/ produces "?").
        if e.key == "?" {
            set_help_open.update(cx, |v| *v = !*v);
            return;
        }

        // Arrow / j/k navigation inside the palette.
        if palette_open_r.get() {
            let count = COMMANDS.len();
            match e.key.as_str() {
                "ArrowDown" | "j" => {
                    set_sel.update(cx, |s| *s = (*s + 1) % count);
                }
                "ArrowUp" | "k" => {
                    set_sel.update(cx, |s| *s = if *s == 0 { count - 1 } else { *s - 1 });
                }
                "Enter" => {
                    let idx = selected_r.get();
                    let cmd = &COMMANDS[idx.min(count - 1)];
                    set_act.set(cx, format!("Executed: {}", cmd.name));
                    set_palette_close.set(cx, false);
                }
                _ => {}
            }
        }
    });

    // ── use_key_up: track key release for modifier display ─────────
    let set_key_up = set_last_key.clone();
    use_key_up(move |e: &KeyboardEvent, _w, cx| {
        // Append " ↑" on key release so the inspector shows down/up cycle.
        if e.key == "Shift" || e.key == "Control" || e.key == "Alt" || e.key == "Meta" {
            set_key_up.update(cx, |s| {
                *s = format!("{} ↑", e.key);
            });
        }
    });

    // ── View ───────────────────────────────────────────────────────
    let set_pal = set_palette.clone();
    let set_act_btn = set_last_action.clone();
    let set_pal_btn = set_palette.clone();

    view! {
        <div class="flex flex-col gap-3 p-6 bg-[#1e1e2e] w-full h-full text-white rounded-lg overflow-hidden">
            // ── Header ──────────────────────────────────────────────
            <div class="flex flex-row items-center justify-between">
                <h2 class="text-lg font-bold text-[#cdd6f4]">{"Keyboard Events"}</h2>
                <span class="text-xs text-[#6c7086]">
                    {"Ctrl+K palette · ? help · Esc close"}
                </span>
            </div>

            // ── Last action banner ──────────────────────────────────
            <div class="px-3 py-2 rounded bg-[#313244] text-sm text-[#a6e3a1] font-mono">
                {last_action.get()}
            </div>

            // ── Key event inspector (element-level on:keydown) ──────
            // This div has its own on:keydown handler that shows the
            // raw KeyboardEvent fields. It calls stop_propagation on
            // Tab so the global handler doesn't also process it —
            // demonstrating the bubbling control.
            <div
                class="px-3 py-2 rounded bg-[#181825] border border-[#313244] text-xs font-mono text-[#94e2d5] cursor_pointer"
                tabindex="0"
                on:keydown={move |e: &KeyboardEvent, _w, _cx| {
                    // When the inspector itself is focused, swallow Tab
                    // so focus stays here and the global handler doesn't
                    // re-process it.
                    if e.key == "Tab" {
                        e.stop_propagation();
                    }
                }}
            >
                <div class="text-[#6c7086] mb-1">{"Click here, then press keys — Tab is captured locally:"}</div>
                <div class="text-[#cdd6f4] text-sm">
                    {if last_key.get().is_empty() {
                        "—".to_string()
                    } else {
                        last_key.get()
                    }}
                </div>
            </div>

            // ── Palette toggle button ───────────────────────────────
            <div class="flex flex-row gap-2">
                <button
                    class="px-3 py-2 rounded bg-[#89b4fa] hover:bg-[#74a0f0] text-[#1e1e2e] text-sm font-medium"
                    on:click={click(move |cx| {
                        set_pal.update(cx, |v| *v = !*v);
                        set_selected.set(cx, 0);
                    })}
                >
                    {"Open Palette (Ctrl+K)"}
                </button>
                <button
                    class="px-3 py-2 rounded bg-[#313244] hover:bg-[#45475a] text-[#cdd6f4] text-sm"
                    on:click={click(move |cx| set_act_btn.set(cx, "Button clicked".to_string()))}
                >
                    {"Dummy Action"}
                </button>
            </div>

            // ── Command palette overlay ─────────────────────────────
            // Rendered inline (not a <dialog>) so the keyboard event
            // layering is visible: global use_key_down handles arrow
            // navigation and Enter, while the palette is just a visual
            {show_when(palette_open.get(), command_palette(selected.get()))}

            // ── Help overlay ────────────────────────────────────────
            {show_when(help_open.get(), help_panel(set_pal_btn.clone()))}
        </div>
    }
}

/// Command palette overlay — a visual surface for keyboard navigation.
/// All keyboard handling (arrows, j/k, Enter, Esc) is done by the global
/// `use_key_down` handler in `app()`. This component is purely presentational.
#[allow(non_snake_case)]
fn command_palette(selected: usize) -> impl gpui::IntoElement {
    let cmds: Vec<(usize, &'static Command)> = COMMANDS.iter().enumerate().collect();
    view! {
        <div class="absolute inset-0 bg-black/40 flex items-start justify-center pt-12 z-10">
            <div class="bg-[#1e1e2e] border border-[#313244] rounded-lg w-[400px] flex flex-col gap-1 p-2">
                <div class="px-3 py-2 text-xs text-[#6c7086] border-b border-[#313244] mb-1">
                    {"Type Up/Down or j/k to navigate - Enter to execute - Esc to close"}
                </div>
                {vgui::for_each(cmds, move |(idx, cmd), _| {
                    let class = if idx == selected {
                        "px-3 py-2 rounded text-sm flex flex-row justify-between items-center cursor_pointer bg-[#89b4fa] text-[#1e1e2e]"
                    } else {
                        "px-3 py-2 rounded text-sm flex flex-row justify-between items-center cursor_pointer text-[#cdd6f4] hover:bg-[#313244]"
                    };
                    view! {
                        <div class={class}>
                            <span>{cmd.name}</span>
                            <span class="text-xs font-mono opacity-60">{cmd.shortcut}</span>
                        </div>
                    }
                })}
            </div>
        </div>
    }
}

/// Help overlay explaining the three keyboard event layers.
#[allow(non_snake_case)]
fn help_panel(close_setter: WriteSignal<bool>) -> impl gpui::IntoElement {
    view! {
        <div class="absolute inset-0 bg-black/40 flex items-center justify-center z-20">
            <div class="bg-[#1e1e2e] border border-[#313244] rounded-lg w-[380px] p-5 flex flex-col gap-3">
                <h3 class="text-sm font-bold text-[#cdd6f4]">{"Keyboard Event Layers"}</h3>
                <div class="flex flex-col gap-2 text-xs text-[#a6adc8]">
                    <div>
                        <span class="text-[#89b4fa] font-mono">{"use_key_down"}</span>
                        {" — global handler, fires on every key press regardless of focus. Handles Ctrl+K, Escape, ?, arrows, j/k, Enter."}
                    </div>
                    <div>
                        <span class="text-[#f9e2af] font-mono">{"on:keydown"}</span>
                        {" — element-level handler on the inspector div. Calls stop_propagation() on Tab to prevent the global handler from reprocessing it."}
                    </div>
                    <div>
                        <span class="text-[#a6e3a1] font-mono">{"use_key_up"}</span>
                        {" — global key-release handler, tracks modifier key up events."}
                    </div>
                    <div>
                        <span class="text-[#f38ba8] font-mono">{"stop_propagation"}</span>
                        {" — sets a flag on KeyboardEvent that halts dispatch to subsequent global handlers and prevents gpui event bubbling."}
                    </div>
                </div>
                <button
                    class="px-3 py-2 rounded bg-[#313244] hover:bg-[#45475a] text-[#cdd6f4] text-sm self-end"
                    on:click={click(move |cx| close_setter.set(cx, false))}
                >
                    {"Close (Esc)"}
                </button>
            </div>
        </div>
    }
}

/// Format a `KeyboardEvent` into a compact inspector string showing all
/// fields: key, code, modifiers, repeat, key_char.
fn format_key_event(e: &KeyboardEvent) -> String {
    let mut mods = String::new();
    if e.ctrl_key { mods.push_str("Ctrl+"); }
    if e.shift_key { mods.push_str("Shift+"); }
    if e.alt_key { mods.push_str("Alt+"); }
    if e.meta_key { mods.push_str("Meta+"); }
    let repeat = if e.repeat { " (repeat)" } else { "" };
    let kc = e.key_char.as_deref().unwrap_or("");
    format!(
        "{}{}  [code:{}]  char:{:?}{}",
        mods, e.key, e.code, kc, repeat,
    )
}

fn run() {
    #[cfg(not(target_family = "wasm"))]
    let gpui_app = application();

    #[cfg(target_family = "wasm")]
    let gpui_app = single_threaded_web();

    let launch = |cx: &mut App| {
        let bounds = Bounds::centered(None, size(px(560.), px(440.0)), cx);
        cx.open_window(
            WindowOptions {
                window_bounds: Some(WindowBounds::Windowed(bounds)),
                ..Default::default()
            },
            |window, cx| vgui::mount(window, cx, app),
        )
        .unwrap();
    };

    #[cfg(not(target_family = "wasm"))]
    gpui_app.run(launch);

    #[cfg(target_family = "wasm")]
    std::mem::forget(gpui_app.run_embedded(launch));
}

#[cfg(not(target_family = "wasm"))]
fn main() {
    run();
}

#[cfg(target_family = "wasm")]
#[wasm_bindgen::prelude::wasm_bindgen(start)]
pub fn start() {
    gpui_platform::web_init();
    vgui::intercept_keyboard_events();
    run();
}

Key Concepts

Three layers of keyboard handling

vgui provides three complementary ways to respond to keyboard input. This example demonstrates all three in a single command-palette UI:

use_key_down / use_key_up — global hooks

Register a handler that fires on every key press (or release) regardless of which element has focus. Internally, the handler is stored in the render Scope and dispatched by VguiRoot from the root element’s on_key_down / on_key_up listener.

#![allow(unused)]
fn main() {
use_key_down(move |e: &KeyboardEvent, _w, cx| {
    if e.ctrl_key && e.key == "k" {
        set_palette_open.update(cx, |v| *v = !*v);
    }
});
}

In the example, the global handler manages:

  • Ctrl+K — toggle the command palette
  • Escape — close any open overlay
  • ? — toggle the help panel
  • Arrow keys / j / k — navigate palette entries
  • Enter — execute the selected command

use_key_up tracks modifier-key releases (Shift, Control, Alt, Meta) to show the down/up cycle in the inspector.

on:keydown — element-level handler

Individual elements can listen for keyboard events when they have focus. The inspector div uses tabindex="0" to become focusable and on:keydown to display the raw KeyboardEvent fields (key, code, modifiers, repeat, key_char).

stop_propagation — controlling event dispatch

KeyboardEvent::stop_propagation() sets a flag that:

  1. Halts global dispatchVguiRoot checks the flag after each global handler and breaks out of the dispatch loop.
  2. Stops gpui event bubbling — the __dom_key_down / __dom_key_up wrappers call cx.stop_propagation() after the user closure returns if the flag is set.

In the example, the inspector div calls e.stop_propagation() when Tab is pressed while it has focus. This prevents the global use_key_down handler from also processing the Tab key, keeping focus trapped within the inspector.

#![allow(unused)]
fn main() {
on:keydown={move |e: &KeyboardEvent, _w, _cx| {
    if e.key == "Tab" {
        e.stop_propagation();
    }
}}
}

KeyboardEvent fields

The KeyboardEvent struct exposes Web-standard fields:

FieldTypeDescription
keyStringThe key value (e.g. "a", "Enter", "Shift")
codeStringThe physical key code (e.g. "KeyA", "Enter")
ctrl_keyboolControl modifier held
shift_keyboolShift modifier held
alt_keyboolAlt modifier held
meta_keyboolMeta (Super/Command) modifier held
repeatboolKey is auto-repeating
key_charOption<String>Produced character, if any

Running

Native

cargo run -p vgui-keyboard-events

Web (WASM)

cargo build --target wasm32-unknown-unknown -p vgui-keyboard-events --release
wasm-bindgen --target web --out-dir examples/keyboard-events/dist \
    --no-typescript target/wasm32-unknown-unknown/release/keyboard-events.wasm
python3 scripts/serve_plain.py 8080 examples/keyboard-events

Keyboard Shortcuts Example

Live Demo

Source Code

#![cfg_attr(target_family = "wasm", no_main)]

use std::collections::HashMap;

use gpui::{px, size, App, Bounds, WindowBounds, WindowOptions};
use vgui::prelude::*;
use vgui::show_when;
use vgui::{CommandOptions, ContextOptions, KeymapOptions};

#[cfg(not(target_family = "wasm"))]
use gpui_platform::application;

#[cfg(target_family = "wasm")]
use gpui_platform::single_threaded_web;

/// A command entry in the palette.
struct Command {
    name: &'static str,
    shortcut: &'static str,
}

const COMMANDS: &[Command] = &[
    Command { name: "New File",      shortcut: "Ctrl+N" },
    Command { name: "Save",          shortcut: "Ctrl+S" },
    Command { name: "Save As",       shortcut: "Ctrl+Shift+S" },
    Command { name: "Find",          shortcut: "Ctrl+F" },
    Command { name: "Open Palette",  shortcut: "Ctrl+K, Ctrl+P" },
    Command { name: "Quit",          shortcut: "Ctrl+Q" },
];

fn app() -> impl gpui::IntoElement {
    // ── State ──────────────────────────────────────────────────────
    let (palette_open, set_palette) = create_signal(false);
    let (selected, set_selected) = create_signal(0usize);
    let (last_action, set_last_action) =
        create_signal(String::from("Press Ctrl+K, Ctrl+P for palette"));
    let (partial_label, set_partial_label) = create_signal(String::new());

    // ── Shortcuts engine (persisted across re-renders) ─────────────
    let sc = use_shortcuts();

    // One-time initialization: keymap, handlers, partial-change listener,
    // and initial context. On re-renders the has_keymap guard skips this
    // block, preserving the engine state (sequence cursors, context stack).
    if !sc.has_keymap() {
        // ── Keymap ──────────────────────────────────────────────────
        let mut commands = HashMap::new();
        commands.insert("new_file".to_string(), CommandOptions {
            shortcut: "Ctrl+N".to_string(), event: None, prevent_default: None, interceptors: None,
        });
        commands.insert("save".to_string(), CommandOptions {
            shortcut: "Ctrl+S".to_string(), event: None, prevent_default: None, interceptors: None,
        });
        commands.insert("save_as".to_string(), CommandOptions {
            shortcut: "Ctrl+Shift+S".to_string(), event: None, prevent_default: None, interceptors: None,
        });
        commands.insert("find".to_string(), CommandOptions {
            shortcut: "Ctrl+F".to_string(), event: None, prevent_default: None, interceptors: None,
        });
        commands.insert("palette".to_string(), CommandOptions {
            shortcut: "Ctrl+K,Ctrl+P".to_string(), event: None, prevent_default: None, interceptors: None,
        });
        commands.insert("quit".to_string(), CommandOptions {
            shortcut: "Ctrl+Q".to_string(), event: None, prevent_default: None, interceptors: None,
        });
        // Palette-context commands
        commands.insert("next".to_string(), CommandOptions {
            shortcut: "Down".to_string(), event: None, prevent_default: None, interceptors: None,
        });
        commands.insert("prev".to_string(), CommandOptions {
            shortcut: "Up".to_string(), event: None, prevent_default: None, interceptors: None,
        });
        commands.insert("select".to_string(), CommandOptions {
            shortcut: "Enter".to_string(), event: None, prevent_default: None, interceptors: None,
        });
        commands.insert("close".to_string(), CommandOptions {
            shortcut: "Escape".to_string(), event: None, prevent_default: None, interceptors: None,
        });

        let mut contexts = HashMap::new();
        contexts.insert("global".to_string(), ContextOptions {
            commands: vec!["new_file".to_string(), "save".to_string(), "save_as".to_string(),
                           "find".to_string(), "palette".to_string(), "quit".to_string()],
            abstract_ctx: None, fallbacks: None,
        });
        contexts.insert("palette".to_string(), ContextOptions {
            commands: vec!["next".to_string(), "prev".to_string(),
                           "select".to_string(), "close".to_string()],
            abstract_ctx: None, fallbacks: Some(vec!["global".to_string()]),
        });

        sc.keymap(KeymapOptions { commands, contexts });

        // ── Partial-match indicator ────────────────────────────────
        // The listener receives cx, so it can update the signal directly.
        let set_pl = set_partial_label.clone();
        sc.on_partial_change(move |names: &[String], cx| {
            if names.is_empty() {
                set_pl.set(cx, String::new());
            } else {
                set_pl.set(cx, format!("Partial: {}", names.join(", ")));
            }
        });

        // ── Global command handlers ────────────────────────────────
        let set_act = set_last_action.clone();
        sc.on("new_file", move |_sev, _w, cx| {
            set_act.set(cx, "New File".to_string());
        });
        let set_act = set_last_action.clone();
        sc.on("save", move |_sev, _w, cx| {
            set_act.set(cx, "Save".to_string());
        });
        let set_act = set_last_action.clone();
        sc.on("save_as", move |_sev, _w, cx| {
            set_act.set(cx, "Save As".to_string());
        });
        let set_act = set_last_action.clone();
        sc.on("find", move |_sev, _w, cx| {
            set_act.set(cx, "Find".to_string());
        });
        let set_act = set_last_action.clone();
        sc.on("quit", move |_sev, _w, cx| {
            set_act.set(cx, "Quit".to_string());
        });

        // ── Palette command (sequence: Ctrl+K, Ctrl+P) ─────────────
        let set_pal = set_palette.clone();
        let set_sel2 = set_selected.clone();
        let sc_pal = sc.clone();
        sc.on("palette", move |_sev, _w, cx| {
            set_pal.set(cx, true);
            set_sel2.set(cx, 0);
            sc_pal.set_context("palette");
        });

        // ── Palette-context commands ───────────────────────────────
        let set_sel_p = set_selected.clone();
        sc.on("next", move |_sev, _w, cx| {
            let count = COMMANDS.len();
            set_sel_p.update(cx, |s| *s = (*s + 1) % count);
        });
        let set_sel_p = set_selected.clone();
        sc.on("prev", move |_sev, _w, cx| {
            let count = COMMANDS.len();
            set_sel_p.update(cx, |s| *s = if *s == 0 { count - 1 } else { *s - 1 });
        });
        let set_act_p = set_last_action.clone();
        let set_pal_p = set_palette.clone();
        let selected_clone = selected.clone();
        let sc_close = sc.clone();
        sc.on("select", move |_sev, _w, cx| {
            let idx = selected_clone.get();
            let cmd = &COMMANDS[idx.min(COMMANDS.len() - 1)];
            set_act_p.set(cx, format!("Executed: {}", cmd.name));
            set_pal_p.set(cx, false);
            sc_close.set_context("global");
        });
        let set_pal_c = set_palette.clone();
        let sc_close2 = sc.clone();
        sc.on("close", move |_sev, _w, cx| {
            set_pal_c.set(cx, false);
            sc_close2.set_context("global");
        });

        // Start in the global context
        sc.set_context("global");
    }

    // ── View ───────────────────────────────────────────────────────
    let cmds: Vec<(usize, &'static Command)> = COMMANDS.iter().enumerate().collect();

    view! {
        <div class="flex flex-col gap-3 p-6 bg-[#1e1e2e] w-full h-full text-white rounded-lg overflow-hidden">
            // ── Header ──────────────────────────────────────────────
            <div class="flex flex-row items-center justify-between">
                <h2 class="text-lg font-bold text-[#cdd6f4]">{"Keyboard Shortcuts"}</h2>
                <span class="text-xs text-[#6c7086]">
                    {"Ctrl+K, Ctrl+P → palette · Esc → close"}
                </span>
            </div>

            // ── Last action banner ──────────────────────────────────
            <div class="px-3 py-2 rounded bg-[#313244] text-sm text-[#a6e3a1] font-mono">
                {last_action.get()}
            </div>

            // ── Partial-match indicator ─────────────────────────────
            {show_when(!partial_label.get().is_empty(), view! {
                <div class="px-3 py-1 rounded bg-[#181825] border border-[#f9e2af] text-xs font-mono text-[#f9e2af]">
                    {partial_label.get()}
                </div>
            })}

            // ── Shortcut reference ──────────────────────────────────
            <div class="flex flex-col gap-1 px-3 py-2 rounded bg-[#181825] border border-[#313244] text-xs font-mono">
                <div class="text-[#6c7086] mb-1">{"Shortcuts (try these):"}</div>
                {vgui::for_each(cmds, move |(_i, cmd), _| {
                    view! {
                        <div class="flex flex-row justify-between text-[#cdd6f4]">
                            <span>{cmd.name}</span>
                            <span class="text-[#89b4fa]">{cmd.shortcut}</span>
                        </div>
                    }
                })}
            </div>

            // ── Command palette overlay ─────────────────────────────
            {show_when(palette_open.get(), command_palette(selected.get()))}
        </div>
    }
}

/// Command palette overlay — a visual surface for keyboard navigation.
/// All keyboard handling is done by the `shortcuts` engine; this component
/// is purely presentational.
#[allow(non_snake_case)]
fn command_palette(selected: usize) -> impl gpui::IntoElement {
    let cmds: Vec<(usize, &'static Command)> = COMMANDS.iter().enumerate().collect();
    view! {
        <div class="absolute inset-0 bg-black/40 flex items-start justify-center pt-12 z-10">
            <div class="bg-[#1e1e2e] border border-[#313244] rounded-lg w-[400px] flex flex-col gap-1 p-2">
                <div class="px-3 py-2 text-xs text-[#6c7086] border-b border-[#313244] mb-1">
                    {"Up/Down to navigate · Enter to execute · Esc to close"}
                </div>
                {vgui::for_each(cmds, move |(idx, cmd), _| {
                    let class = if idx == selected {
                        "px-3 py-2 rounded text-sm flex flex-row justify-between items-center bg-[#89b4fa] text-[#1e1e2e]"
                    } else {
                        "px-3 py-2 rounded text-sm flex flex-row justify-between items-center text-[#cdd6f4] hover:bg-[#313244]"
                    };
                    view! {
                        <div class={class}>
                            <span>{cmd.name}</span>
                            <span class="text-xs font-mono opacity-60">{cmd.shortcut}</span>
                        </div>
                    }
                })}
            </div>
        </div>
    }
}

fn run() {
    #[cfg(not(target_family = "wasm"))]
    let gpui_app = application();

    #[cfg(target_family = "wasm")]
    let gpui_app = single_threaded_web();

    let launch = |cx: &mut App| {
        let bounds = Bounds::centered(None, size(px(560.), px(480.0)), cx);
        cx.open_window(
            WindowOptions {
                window_bounds: Some(WindowBounds::Windowed(bounds)),
                ..Default::default()
            },
            |window, cx| vgui::mount(window, cx, app),
        )
        .unwrap();
    };

    #[cfg(not(target_family = "wasm"))]
    gpui_app.run(launch);

    #[cfg(target_family = "wasm")]
    std::mem::forget(gpui_app.run_embedded(launch));
}

#[cfg(not(target_family = "wasm"))]
fn main() {
    run();
}

#[cfg(target_family = "wasm")]
#[wasm_bindgen::prelude::wasm_bindgen(start)]
pub fn start() {
    gpui_platform::web_init();
    vgui::intercept_keyboard_events();
    run();
}

Key Concepts

use_shortcuts — declarative shortcut engine

vgui integrates the shortcuts crate to provide configurable keyboard shortcuts with human-readable combination strings ("Ctrl+K, Ctrl+P"), a macro registry, a stateful sequence matcher, a context stack with fallbacks, and an interceptor middleware chain.

#![allow(unused)]
fn main() {
let sc = use_shortcuts();
sc.keymap(KeymapOptions { commands, contexts });
sc.on("save", move |_sev, _w, cx| { /* ... */ });
}

Shortcut combinations

Shortcuts are expressed as strings using + for simultaneous keys and , for sequential steps:

CombinationMeaning
"Ctrl+S"Ctrl and S pressed together
"Ctrl+Shift+S"Ctrl, Shift, and S together (exact modifier match)
"Ctrl+K,Ctrl+P"Ctrl+K, then Ctrl+P in sequence
"G,G"G pressed twice in sequence

Context stack with fallbacks

Commands are grouped into named contexts. A context can declare fallbacks — other contexts whose commands are inherited. The switch_context method pushes a context onto the stack; a Drop guard pops it automatically.

#![allow(unused)]
fn main() {
// "palette" context inherits all commands from "global"
contexts.insert("palette", ContextOptions {
    commands: vec!["next", "prev", "select", "close"],
    fallbacks: Some(vec!["global".to_string()]),
    ..
});
}

Partial-match tracking

When a multi-step shortcut (e.g. Ctrl+K, Ctrl+P) is mid-sequence, the engine reports the partially-matched command names via on_partial_change. The example displays a yellow indicator showing which command is awaiting its next key.

prevent_defaultstop_propagation

The TS library’s preventDefault (DOM default-action cancel) maps to KeyboardEvent::stop_propagation() in vgui. When a command matches and prevent_default is true (the default), the dispatcher calls e.stop_propagation(), preventing later hand-written use_key_down handlers from also processing the key.

Running

Native

cargo run -p vgui-keyboard-shortcuts

Web (WASM)

cargo build --target wasm32-unknown-unknown -p vgui-keyboard-shortcuts --release
wasm-bindgen --target web --out-dir examples/keyboard-shortcuts/dist \
    --no-typescript target/wasm32-unknown-unknown/release/keyboard-shortcuts.wasm
python3 scripts/serve_plain.py 8080 examples/keyboard-shortcuts

Virtual List (<uniform_list>)

<uniform_list> exposes gpui’s uniform_list primitive for rendering large fixed-height lists efficiently. Only the visible items are materialised — 10,000+ item lists scroll smoothly because off-screen rows are never laid out or painted.

Syntax

#![allow(unused)]
fn main() {
view! {
    <uniform_list
        count={item_count}
        render={|range, window, cx| {
            range.map(|i| view! { <div>{format!("Item {i}")}</div> }).collect()
        }}
    />
}
}

Attributes

AttributeRequiredDescription
countYesusize — total number of items.
renderYesClosure Fn(Range<usize>, &mut Window, &mut App) -> Vec<R> where R: IntoElement.
scroll_handleNoUniformListScrollHandle for programmatic scroll control.
styleNoCSS-in-Rust style object (css! { ... }).
classNoTailwind class string.

<uniform_list> cannot have children. Unsupported attributes produce a compile-time error.

Height Constraint

The element or an ancestor must have a definite or max height for virtualization to engage. Without it, gpui’s ListSizingBehavior::Infer makes the list as tall as all items (no virtualization).

Use h-full on the <uniform_list> and ensure the parent has a bounded height, or use an explicit height like h-[500px]:

#![allow(unused)]
fn main() {
view! {
    <div class="flex-1 min-h-0">
        <uniform_list
            count={count}
            render={|range, _w, _cx| { /* ... */ }}
            class="h-full overflow-y-scroll"
        />
    </div>
}
}

Programmatic Scroll

Use use_scroll_handle() to obtain a persistent UniformListScrollHandle, then pass it to scroll_handle={...}:

#![allow(unused)]
fn main() {
let scroll_handle = use_scroll_handle();

view! {
    <button on:click={click({
        let sh = scroll_handle.clone();
        move |cx| {
            sh.scroll_to_item(5000, ScrollStrategy::Top);
            cx.refresh_windows();
        }
    })}>
        {"Scroll to 5000"}
    </button>
    <uniform_list
        count={count}
        render={|range, _w, _cx| { /* ... */ }}
        scroll_handle={scroll_handle.clone()}
    />
}
}

Available methods on UniformListScrollHandle:

  • scroll_to_item(ix, ScrollStrategy::Top|Center|Bottom|Nearest)
  • scroll_to_bottom()
  • is_scrolled_to_end() -> Option<bool>

ScrollStrategy variants: Top, Center, Bottom, Nearest.

Render Closure Constraints

  • Fn (not FnMut): gpui requires the closure to be Fn. Use Rc<RefCell<T>> for interior mutability inside the closure.

  • Signal reads work: ReadSignal::get() reads from a cached value and does not require an active reactive scope. The closure runs during gpui’s prepaint phase (after render), so cached signal values are available.

  • No signal creation: create_signal inside the closure does not work — no reactive scope is active during prepaint. Create signals in the view! body (the render scope) and capture them by clone.

Dynamic Item Counts

The count attribute is re-evaluated every render. When a signal drives the count, updating the signal causes a re-render with the new count:

#![allow(unused)]
fn main() {
let (count, set_count) = create_signal(10_000usize);

view! {
    <uniform_list
        count={count.get()}
        render={|range, _w, _cx| { /* ... */ }}
    />
}
}

Window Management Example

Live Demo

Overview

This example demonstrates vgui’s window management API — controlling the window (title, minimize, maximize, close, drag) from reactive scope, opening additional windows, setting native app menus, and intercepting window close.

  • with_window for imperative window control (title, minimize, maximize, close).
  • on:pointerdown with start_window_move for a custom draggable titlebar.
  • open_window for multi-window support.
  • set_app_menus for native menu bar.
  • use_window_should_close for close interception.
  • WindowOptions with TitlebarOptions, WindowDecorations::Client, and window_min_size.

Source Code

#![cfg_attr(target_family = "wasm", no_main)]

use gpui::{px, size, App, Bounds, TitlebarOptions, WindowBounds, WindowDecorations, WindowOptions};
use vgui::prelude::*;

#[cfg(not(target_family = "wasm"))]
use gpui_platform::application;

#[cfg(target_family = "wasm")]
use gpui_platform::single_threaded_web;

// Define simple unit-struct actions for the app menu.
gpui::actions!(window_mgmt, [NewWindow, Close, ToggleFullscreen, Minimize]);

fn app() -> impl gpui::IntoElement {
    let (title, set_title) = create_signal("vgui Window Management".to_string());
    let (maximized, set_maximized) = create_signal(false);
    let (saved_size, set_saved_size) = create_signal(None::<gpui::Size<gpui::Pixels>>);

    // Set app menus (idempotent — safe to call every render).
    set_app_menus(vec![
        Menu::new("File").items(vec![
            MenuItem::Separator,
            MenuItem::action("New Window", NewWindow),
            MenuItem::action("Close", Close),
        ]),
        Menu::new("View").items(vec![
            MenuItem::action("Toggle Fullscreen", ToggleFullscreen),
            MenuItem::action("Minimize", Minimize),
        ]),
    ]);

    // Register close-interception handler once per window (slot-guarded).
    use_window_should_close(|_window, _cx| {
        // Return true to allow close, false to prevent.
        true
    });

    // Update window title reactively.
    let current_title = title.get();
    let _ = with_window(|window, _| window.set_window_title(&current_title));

    view! {
        <div class="w-full h-full flex flex-col bg-[#2b2b2b] text-white select-none">
            // Custom titlebar — drag to move window.
            <div
                class="h-10 flex items-center justify-between px-3 bg-[#1e1e1e] border-b border-[#3a3a3a] cursor-default"
                on:pointerdown={move |_e, window, _cx| window.start_window_move()}
            >
                <span class="text-sm font-medium truncate">{title.get()}</span>
                <div class="flex gap-2">
                    <button
                        class="w-7 h-6 rounded bg-[#3a3a3a] hover:bg-[#505050] text-xs flex items-center justify-center"
                        on:pointerdown={move |_e, _w, cx| cx.stop_propagation()}
                        on:click={move |_, window, _cx| window.minimize_window()}
                    >
                        {"–"}
                    </button>
                    <button
                        class="w-7 h-6 rounded bg-[#3a3a3a] hover:bg-[#505050] text-xs flex items-center justify-center"
                        on:pointerdown={move |_e, _w, cx| cx.stop_propagation()}
                        on:click={{
                            let maximized = maximized.clone();
                            let saved_size = saved_size.clone();
                            let set_maximized = set_maximized.clone();
                            let set_saved_size = set_saved_size.clone();
                            move |_, window, cx| {
                                if maximized.get() {
                                    if let Some(sz) = saved_size.get() {
                                        window.resize(sz);
                                    }
                                    set_maximized.set(cx, false);
                                } else {
                                    let old = window.bounds().size;
                                    set_saved_size.set(cx, Some(old));
                                    if let Some(display) = window.display(cx) {
                                        let vb = display.visible_bounds();
                                        window.resize(vb.size);
                                    }
                                    set_maximized.set(cx, true);
                                }
                            }
                        }}
                    >
                        {"□"}
                    </button>
                    <button
                        class="w-7 h-6 rounded bg-[#e02424] hover:bg-[#b01a1a] text-xs flex items-center justify-center"
                        on:pointerdown={move |_e, _w, cx| cx.stop_propagation()}
                        on:click={move |_, window, _cx| window.remove_window()}
                    >
                        {"×"}
                    </button>
                </div>
            </div>

            // Content area.
            <div class="flex-1 flex flex-col gap-4 p-6 overflow-auto">
                <h1 class="text-xl font-bold">{"Window Management"}</h1>

                <div class="flex flex-col gap-2">
                    <label class="text-sm text-gray-300">{"Window Title"}</label>
                    <div class="flex gap-2">
                        <input
                            class="flex-1 px-3 py-2 rounded bg-[#3a3a3a] border border-[#555] text-white text-sm outline-none focus:border-[#007acc]"
                            type="text"
                            value={title.get()}
                            on:input={move |v: &str, cx: &mut App| set_title.set(cx, v.to_string())}
                        />
                    </div>
                    <p class="text-xs text-gray-400">
                        {"The window title updates reactively as you type."}
                    </p>
                </div>

                <div class="flex flex-col gap-2">
                    <h2 class="text-sm font-semibold text-gray-300">{"Window Controls"}</h2>
                    <div class="flex gap-2 flex-wrap">
                        <button
                            class="px-3 py-2 rounded bg-[#007acc] hover:bg-[#005a9e] text-sm"
                            on:click={move |_, window, _cx| window.minimize_window()}
                        >
                            {"Minimize"}
                        </button>
                        <button
                            class="px-3 py-2 rounded bg-[#007acc] hover:bg-[#005a9e] text-sm"
                            on:click={{
                                let maximized = maximized.clone();
                                let saved_size = saved_size.clone();
                                let set_maximized = set_maximized.clone();
                                let set_saved_size = set_saved_size.clone();
                                move |_, window, cx| {
                                    if maximized.get() {
                                        if let Some(sz) = saved_size.get() {
                                            window.resize(sz);
                                        }
                                        set_maximized.set(cx, false);
                                    } else {
                                        let old = window.bounds().size;
                                        set_saved_size.set(cx, Some(old));
                                        if let Some(display) = window.display(cx) {
                                            let vb = display.visible_bounds();
                                            window.resize(vb.size);
                                        }
                                        set_maximized.set(cx, true);
                                    }
                                }
                            }}
                        >
                            {if maximized.get() { "Restore" } else { "Maximize" }}
                        </button>
                        <button
                            class="px-3 py-2 rounded bg-[#e02424] hover:bg-[#b01a1a] text-sm"
                            on:click={move |_, window, _cx| window.remove_window()}
                        >
                            {"Close Window"}
                        </button>
                    </div>
                    <p class="text-xs text-gray-400">
                        {"The titlebar area above can be dragged to move the window."}
                    </p>
                </div>

                <div class="flex flex-col gap-2">
                    <h2 class="text-sm font-semibold text-gray-300">{"Multi-Window"}</h2>
                    <button
                        class="px-3 py-2 rounded bg-[#2d7d46] hover:bg-[#236b3a] text-sm self-start"
                        on:click={click(move |cx| {
                            let _ = vgui::open_window(
                                cx,
                                WindowOptions {
                                    window_bounds: Some(WindowBounds::Windowed(Bounds::centered(
                                        None,
                                        size(px(400.), px(300.)),
                                        cx,
                                    ))),
                                    ..Default::default()
                                },
                                app,
                            );
                        })}
                    >
                        {"Open New Window"}
                    </button>
                    <p class="text-xs text-gray-400">
                        {"Opens a second independent window with the same app."}
                    </p>
                </div>

                <div class="flex flex-col gap-2">
                    <h2 class="text-sm font-semibold text-gray-300">{"App Menus"}</h2>
                    <p class="text-xs text-gray-400">
                        {"The native menu bar shows File and View menus (visible on macOS / Linux desktop)."}
                    </p>
                </div>
            </div>
        </div>
    }
}

fn run() {
    #[cfg(not(target_family = "wasm"))]
    let gpui_app = application();

    #[cfg(target_family = "wasm")]
    let gpui_app = single_threaded_web();

    let launch = |cx: &mut App| {
        let bounds = Bounds::centered(None, size(px(600.), px(450.0)), cx);
        cx.open_window(
            WindowOptions {
                window_bounds: Some(WindowBounds::Windowed(bounds)),
                titlebar: Some(TitlebarOptions {
                    appears_transparent: true,
                    ..Default::default()
                }),
                window_min_size: Some(size(px(400.), px(300.))),
                window_decorations: Some(WindowDecorations::Client),
                ..Default::default()
            },
            |window, cx| vgui::mount(window, cx, app),
        )
        .unwrap();
    };

    #[cfg(not(target_family = "wasm"))]
    gpui_app.run(launch);

    #[cfg(target_family = "wasm")]
    std::mem::forget(gpui_app.run_embedded(launch));
}

#[cfg(not(target_family = "wasm"))]
fn main() {
    run();
}

#[cfg(target_family = "wasm")]
#[wasm_bindgen::prelude::wasm_bindgen(start)]
pub fn start() {
    gpui_platform::web_init();
    vgui::intercept_keyboard_events();
    run();
}

Key Concepts

with_window — imperative window control

with_window(|window, cx| ...) bridges reactive scope to the gpui window. It reads the AnyWindowHandle stored on VguiRoot at mount time and calls the closure inside cx.update_window. This example uses it to set the window title reactively — called directly in app() so it runs on every render with the current title value.

Custom titlebar with start_window_move

WindowDecorations::Client tells the platform to use client-side decorations (no native titlebar). The custom titlebar div has on:pointerdown={move |_e, window, _cx| window.start_window_move()}, making the entire bar a drag region. Minimize/maximize/close buttons call window.minimize_window(), a custom maximize toggle (using window.display(cx).visible_bounds() + window.resize()), and window.remove_window() respectively. Each button stops pointer-down propagation via cx.stop_propagation() to prevent the parent titlebar’s start_window_move from intercepting clicks.

open_window — multi-window

vgui::open_window(cx, options, app) wraps cx.open_window + mount, returning a WindowHandle<VguiRoot>. Each opened window gets its own independent VguiRoot and reactive scope.

set_app_menus — native menu bar

set_app_menus(vec![Menu::new("File").items(...), ...]) sets the native application menu bar. Menu items use MenuItem::action with actions defined via gpui::actions!. Called directly in app()set_menus is idempotent so repeated calls on re-render are harmless.

use_window_should_close — close interception

Registers a handler called when the user attempts to close the window. Returning false prevents the close. Registration is guarded by a reactive scope slot so it only happens once per window.

WindowOptions

The window is created with TitlebarOptions { appears_transparent: true } for a seamless custom titlebar, window_min_size to prevent the window from shrinking too small, and WindowDecorations::Client for client-side decorations.

Running

Native:

cargo run -p vgui-window-management

Web (WASM):

# Build the WASM binary
cargo build --target wasm32-unknown-unknown -p vgui-window-management --release

# Generate JS bindings
wasm-bindgen --target web --out-dir examples/window-management/dist \
    --no-typescript target/wasm32-unknown-unknown/release/window-management.wasm

# Serve and open in a browser
python3 scripts/serve_plain.py 8080 examples/window-management

Drag & Drop Example

Live Demo

Source Code

#![cfg_attr(target_family = "wasm", no_main)]

use gpui::{hsla, px, size, App, Bounds, Hsla, WindowBounds, WindowOptions};
use vgui::for_each;
use vgui::prelude::*;

#[cfg(not(target_family = "wasm"))]
use gpui_platform::application;

#[cfg(target_family = "wasm")]
use gpui_platform::single_threaded_web;

// ── Drag value types ─────────────────────────────────────────────────

/// A draggable color card. `Clone + Render` lets the default drag
/// preview constructor work without an explicit `drag:preview={...}`.
#[derive(Clone, Copy, PartialEq)]
struct DragItem {
    id: usize,
    color: Hsla,
}

impl Render for DragItem {
    fn render(&mut self, _window: &mut gpui::Window, _cx: &mut gpui::Context<Self>) -> impl gpui::IntoElement {
        let color = self.color;
        let id = self.id;
        view! {
            <div class="w-20 h-20 rounded-lg border-2 flex items-center justify-center text-white text-sm"
                 style={vgui::Css::new(move |s| {
                    s.background = Some(color.into());
                    s.border_color = Some(hsla(0.0, 0.0, 1.0, 0.5).into());
                })}>
                {format!("#{}", id)}
            </div>
        }
    }
}

/// Drag value for tab reordering. Implements `Render` so the default
/// drag preview works.
#[derive(Clone, Copy)]
struct TabDrag {
    ix: usize,
}

impl Render for TabDrag {
    fn render(&mut self, _window: &mut gpui::Window, _cx: &mut gpui::Context<Self>) -> impl gpui::IntoElement {
        view! {
            <div class="px-3 py-1 rounded bg-[#45475a] text-white text-xs">
                {"move tab"}
            </div>
        }
    }
}

// ── App ──────────────────────────────────────────────────────────────

fn app() -> impl gpui::IntoElement {
    // Last dropped color card.
    let (dropped, set_dropped) = create_signal::<Option<DragItem>>(None);

    // Dropped file paths (OS file drag — native only).
    let (files, set_files) = create_signal::<Vec<String>>(Vec::new());

    // Tab reordering.
    let tabs = vec!["Inbox", "Drafts", "Sent", "Archive", "Spam"];
    let (tab_order, set_tab_order) = create_signal::<Vec<usize>>((0..tabs.len()).collect());

    let colors = [
        hsla(0.0, 0.7, 0.5, 1.0),       // red
        hsla(0.33, 0.7, 0.5, 1.0),      // green
        hsla(0.58, 0.7, 0.5, 1.0),      // blue
    ];

    let drop_border = dropped.get().map(|d| d.color).unwrap_or(hsla(0.0, 0.0, 0.5, 1.0));
    let drop_bg = dropped.get().map(|d| hsla(d.color.h, d.color.s, d.color.l, 0.15)).unwrap_or(hsla(0.0, 0.0, 0.0, 0.0));

    view! {
        <div class="flex flex-col gap-4 p-4 bg-[#1e1e2e] w-full h-full text-white overflow-y-auto">
            // ── Draggable color cards ───────────────────────────────
            <div class="flex gap-3">
                {for_each(colors, move |color, ix| {
                    let id = ix + 1;
                    view! {
                        <div drag={DragItem { id, color }}
                             class="w-20 h-20 rounded-lg border-2 flex items-center justify-center text-white text-sm cursor-grab"
                             style={vgui::Css::new(move |s| {
                                s.background = Some(color.into());
                                s.border_color = Some(hsla(0.0, 0.0, 1.0, 0.5).into());
                            })}>
                            {format!("#{}", id)}
                        </div>
                    }
                })}
            </div>

            // ── Drop target ─────────────────────────────────────────
            <div
                class="h-32 rounded-lg border-2 border-dashed flex items-center justify-center text-lg"
                style={vgui::Css::new(move |s| {
                    s.border_color = Some(drop_border.into());
                    s.background = Some(drop_bg.into());
                })}
                on:drop={move |item: &DragItem, _window, cx| {
                    set_dropped.update(cx, |_| Some(*item));
                }}
            >
                {if let Some(d) = dropped.get() {
                    format!("Dropped #{}", d.id)
                } else {
                    "Drop a card here".to_string()
                }}
            </div>

            // ── File drop zone (OS file drag — native only) ─────────
            <div
                class="h-24 rounded-lg border-2 border-dashed border-[#666] flex flex-col items-center justify-center text-sm text-[#aaa] p-2"
                on:drop={move |paths: &ExternalPaths, _window, cx| {
                    set_files.update(cx, |_| paths.paths().iter().map(|p| p.display().to_string()).collect::<Vec<_>>());
                }}
            >
                {if files.get().is_empty() {
                    "Drop files from your file manager here".to_string()
                } else {
                    files.get().join("\n")
                }}
            </div>

            // ── Tab bar with drag reordering ────────────────────────
            <div class="flex gap-1 bg-[#181825] rounded-lg p-1">
                {for_each(tab_order.get(), move |ix, _| {
                    let label = tabs[ix].to_string();
                    let set_tab_order = set_tab_order.clone();
                    view! {
                        <div drag={TabDrag { ix }}
                             on:drop={move |d: &TabDrag, _w, cx| {
                                 set_tab_order.update(cx, |order| {
                                     let from = d.ix;
                                     let to = ix;
                                     if from != to {
                                         let item = order.remove(from);
                                         order.insert(to, item);
                                     }
                                 });
                             }}
                             class="px-4 py-2 rounded bg-[#313244] text-sm cursor-grab hover:bg-[#45475a]">
                            {label}
                        </div>
                    }
                })}
            </div>

            // ── Drag status indicator ───────────────────────────────
            <div class="text-sm text-[#888]">
                {format!("Drag active: {}", has_active_drag())}
            </div>
        </div>
    }
}

// ── Entry points ─────────────────────────────────────────────────────

fn run() {
    #[cfg(not(target_family = "wasm"))]
    let gpui_app = application();

    #[cfg(target_family = "wasm")]
    let gpui_app = single_threaded_web();

    let launch = |cx: &mut App| {
        let bounds = Bounds::centered(None, size(px(520.), px(560.0)), cx);
        cx.open_window(
            WindowOptions {
                window_bounds: Some(WindowBounds::Windowed(bounds)),
                ..Default::default()
            },
            |window, cx| vgui::mount(window, cx, app),
        )
        .unwrap();
    };

    #[cfg(not(target_family = "wasm"))]
    gpui_app.run(launch);

    #[cfg(target_family = "wasm")]
    std::mem::forget(gpui_app.run_embedded(launch));
}

#[cfg(not(target_family = "wasm"))]
fn main() {
    run();
}

#[cfg(target_family = "wasm")]
#[wasm_bindgen::prelude::wasm_bindgen(start)]
pub fn start() {
    gpui_platform::web_init();
    vgui::intercept_keyboard_events();
    run();
}

Key Concepts

drag={value} — starting a drag

Attach drag={value} to any element to make it draggable. When the user clicks and moves more than a few pixels, gpui starts a drag operation carrying the typed value. The element automatically gets an .id() so on_drag can register on the StatefulInteractiveElement.

#![allow(unused)]
fn main() {
<div drag={DragItem { id, color }} class="...">
    {format!("#{}", id)}
</div>
}

Default drag preview

When drag:preview is omitted, vgui generates a default preview constructor that clones the drag value and renders it. This works when the drag value type implements Clone + Render:

#![allow(unused)]
fn main() {
#[derive(Clone, Copy)]
struct DragItem { id: usize, color: Hsla }

impl Render for DragItem { ... }
}

For types that don’t impl Clone + Render, supply an explicit preview constructor:

#![allow(unused)]
fn main() {
<div drag={my_value} drag:preview={move |item, pos, window, cx| {
    cx.new(|_| MyPreview { value: item.clone() })
}} class="...">
}

on:drop — receiving drops

on:drop registers a drop listener. Rust infers the drag value type T from the closure’s first parameter type annotation. The listener fires only when the dropped value’s TypeId matches T.

#![allow(unused)]
fn main() {
<div on:drop={move |item: &DragItem, _window, cx| {
    set_dropped.update(cx, |_| Some(*item));
}} class="...">
}

OS file drops with ExternalPaths

For files dragged from the OS file manager, the drop handler receives &ExternalPaths. This works on native platforms; on WASM the handler compiles but never fires.

#![allow(unused)]
fn main() {
<div on:drop={move |paths: &ExternalPaths, _window, cx| {
    set_files.update(cx, |_| paths.paths().iter().map(|p| p.display().to_string()).collect());
}} class="...">
}

on:drag_move — tracking drag movement

on:drag_move fires while a drag of the matching type is moving over the element. The handler receives &DragMoveEvent<T>, which provides the mouse event, the element bounds, and access to the dragged item.

#![allow(unused)]
fn main() {
<div on:drag_move={move |e: &DragMoveEvent<TabDrag>, _w, _cx| {
    // e.bounds — element bounds
    // e.drag(_cx) — the dragged TabDrag value
}} class="...">
}

can_drop — drop validation

can_drop={predicate} controls whether a drop is allowed. The predicate receives &dyn Any, the window, and the app context, and returns bool.

#![allow(unused)]
fn main() {
<div can_drop={move |value, _window, _cx| {
    value.is::<DragItem>()
}} class="...">
}

has_active_drag() — imperative query

Check whether a drag operation is in progress from any reactive scope:

#![allow(unused)]
fn main() {
{format!("Drag active: {}", has_active_drag())}
}

For stop_active_drag and set_active_drag_cursor_style, call them directly on the App context from within event handlers where &mut Window is available:

#![allow(unused)]
fn main() {
on:click={move |_e, window, cx| {
    cx.stop_active_drag(window);
    cx.set_active_drag_cursor_style(gpui::CursorStyle::Default, window);
}}
}

Tab reordering

The tab bar demonstrates reordering via drag. Each tab carries a TabDrag { ix } value and accepts drops of the same type. On drop, the source index is removed and inserted at the target index:

#![allow(unused)]
fn main() {
<div drag={TabDrag { ix }}
     on:drop={move |d: &TabDrag, _w, cx| {
         set_tab_order.update(cx, |order| {
             let from = d.ix;
             let to = ix;
             if from != to {
                 let item = order.remove(from);
                 order.insert(to, item);
             }
         });
     }}
     class="...">
    {label}
</div>
}

Running

Native

cargo run -p vgui-drag-drop

Web (WASM)

cargo build --target wasm32-unknown-unknown -p vgui-drag-drop --release
wasm-bindgen --target web --out-dir examples/drag-drop/dist \
    --no-typescript target/wasm32-unknown-unknown/release/drag-drop.wasm
python3 scripts/serve_plain.py 8080 examples/drag-drop

vgui Feature Comparison with HTML+CSS

This page compares vgui features against HTML+CSS item by item, helping developers quickly build a comprehensive understanding of vgui’s capability boundaries. Support level markers: ✅ Full support · 🔶 Partial support / differences · ❌ Not supported

1. Markup Language & Elements

1.1 Built-in HTML Element Mapping

vgui’s view! macro accepts lowercase tags matching HTML names, mapping them to gpui elements under the hood. Container elements have no default styling; text elements carry corresponding default font styles.

HTML Elementsvgui MappingNotes
div span p header footer nav main section article aside address form fieldset legend figure figcaption pre blockquote qgpui::div()No default styling, pure containers
h1h6 strong b em i u s del strike mark small code kbd samp var cite abbr dfn bdi bdo timegpui::div()With corresponding default font styles
ul ol li dl dt ddflex column / bold / padding-leftNo list-style; must manually draw numbers/bullets
adiv().cursor_pointer() + blue texthref attribute accepted but not navigable; use on:click for navigation
buttondiv().cursor_pointer()Default tabindex=0
imggpui::img(src)Supports object_fit (fill/contain/cover/scale-down/none); alt accepted but not used for a11y
svggpui::svg().path(src)
br hr wbrempty line / separator / emptyvoid elements
table thead tbody tfoot tr td th captionflex layout simulationcolspanflex_grow; rowspan has no visual effect
colgroup col datalist option optgroupcompiles but renders empty or special-purposeno-op elements
canvasvgui::canvas_element(paint)<canvas paint={|ctx| ...}> with Context2D 2D drawing API; supports style/class, no children/events

1.2 Custom Components

HTML/CSSvguiSupport
No native component system (relies on frameworks)Uppercase tags call functions/structs, attribute→field mapping
Attribute spreading{..props} → Rust struct update syntax / Spread<E> trait

Attribute name mapping conventions: on:clickon_click, typer#type, refr#ref.

1.3 Control Flow

HTML/CSSvguiSupport
No built-in conditional/list rendering (relies on framework v-if/v-for, React conditional rendering, etc.)<Show when={}> / <For each={}> / <Switch>+<Match> / <Index each={}>, all support fallback

2. Styling System

2.1 CSS Property Support (css! Macro)

Layout

HTML/CSS Propertycss! SupportDifferences
display (flex/block/none/grid)
visibility
overflow (+-x/-y)
position (relative/absolute)🔶No fixed/sticky
flex-direction flex-wrap flex flex-grow flex-shrink flex-basis
justify-content align-items align-self align-content
gap (+row-gap column-gap)gap does not support more than 2 values
grid-template-columns grid-template-rowsNo fr unit; use numeric column counts
grid-column grid-row (+-start/-end)
grid-template-areasString literals define named areas; auto-infers column/row counts
grid-area"name" resolves against grid-template-areas, or N / row / col / four values
scrollbar-width

Box Model

HTML/CSS Propertycss! SupportDifferences
width height min-* max-*
padding (+-inline/-block)
margin (+-inline/-block)
inset top right bottom left
aspect-ratio

Visual

HTML/CSS Propertycss! SupportDifferences
background (color + linear-gradient)
background-color color opacity
border (+per-side/-color/-style/-width)
border-radius (+per-corner)
cursor box-shadow

Text

HTML/CSS Propertycss! SupportDifferences
font-sizeDoes not support %
font-weight (named + numeric 100–900)
font-style font-family
text-alignleft/center/right
text-decoration (+-color/-thickness/-style)
text-overflow text-background white-space line-height line-clamp

CSS Variables & Interpolation

CapabilitySupportDifferences
--name: value definitionInside css!
var(--name, fallback) reference
theme! macro + set_theme()Global thread-local theme
{expr} runtime expression interpolation

2.2 Unsupported CSS Properties

CSS PropertyReasonAlternative
transform / translate / rotate / scalegpui has no div transforms❌ None
transition / animation (inside css!)No equivalent in gpui styling modelUse tw! transition-*/animate-* classes
z-index (inside css!)No equivalent in gpui styling modelUse tw! z-N utilities
box-sizinggpui uses content-box semantics❌ None
outlineNo equivalentUse border
list-style / list-style-typeNo list-style renderingManually draw numbers/bullets
background-imageNo equivalentUse linear-gradient
background-position / background-size / background-repeatNo equivalent❌ None
float / clearNo equivalent❌ None
@media queries (inside css!)css! does not support media queriesUse tw! responsive prefixes sm:/md:/lg:/xl:
!importantNo equivalent❌ None
position: fixed / stickygpui has no fixed/sticky positioning❌ None

2.3 Tailwind Utilities (tw! Macro)

Tailwind CategorySupportDifferences
Display: flex/block/hidden/grid/inline-flexinline-flex same as flex
Flex: direction/wrap/grow/shrink
Justify / Align
Position: relative/absolute/staticNo fixed/sticky
Overflow
Spacing: p/m/gap full rangespacing scale 0–96
Sizing: w/h/min/max
Colors: 22 palettes × 11 shades + black/white/transparent
Typography: weight/size/family/line-height/decoration/overflow/style/white-space
Borders: width/style/color/radius
Shadows: sm2xl/none
Cursor / Opacity (0–100) / Inset
Grid: grid-cols-N/grid-rows-N/col-N/row-N
Aspect ratio / Line clamp (1–10)
Z-index: z-0z-50/z-auto
Arbitrary values: [Npx]/[Nrem]/[N%]/[#hex]/[rgb()]/[rgba()]
Opacity modifier: /NN

2.4 Pseudo-States / Variants

CSS Pseudo-Classvgui SupportDifferences
:hoverhover={css!{}} attribute / hover: prefix
:focusfocus={css!{}} attribute / focus: prefix
:activeactive={css!{}} attribute / active: prefix
:focus-within :focus-visible :visited :link :target :nth-child etc.No equivalent
Responsive breakpoints @media (min-width: …)sm:/md:/lg:/xl: prefixes✅ Applied at runtime based on viewport width

Responsive breakpoint thresholds (matching Tailwind defaults):

PrefixMin Width
sm:≥ 640px
md:≥ 768px
lg:≥ 1024px
xl:≥ 1280px

2.5 Animations & Transitions

CSS animation/transitionvguiSupport
@keyframesBuilt-in animate-pulse/bounce/ping
animate-spingpui has no rotation transform
Custom animationsanimate={|el| el.with_animation(...)} closure
transitiontransition/transition-opacity/transition-colors/transition-all
duration-* / ease-*
delay-*Parsed and stored but no effect🔶

2.6 Component Variants System

HTML/CSSvguiSupport
No native variant system (relies on libraries like CVA)variants! macro generates enum + composite struct + ApplyStyle impl

3. Layout Model

3.1 Flexbox

HTML/CSSvguiSupport
CSS flexbox full-featuredBased on gpui flexbox

flex-direction/wrap/grow/shrink/basis/justify/align/gap all fully supported.

3.2 Grid

HTML/CSS GridvguiSupport
grid-template-columns/rows (column/row counts)
grid-column/row (+-start/-end, span N)
grid-template-areas + grid-areathread-local stack parses named areas
fr unit / minmax() / repeat() / auto-fit / auto-fillUse numeric column counts
grid-auto-flow / grid-auto-columns / grid-auto-rowsNo equivalent

3.3 Positioning

HTML/CSSvguiSupport
position: relative / absolute
position: fixed / stickyNo equivalent
top/right/bottom/left/inset

3.4 Table Layout

HTML/CSSvguiSupport
table semantic layoutflex simulation🔶
colspanflex_grow
rowspanNo visual effect
Automatic column width from contentMust manually specify w-[Npx]🔶

4. Responsive Design

HTML/CSSvguiSupport
@media queriestw! responsive prefixes sm:/md:/lg:/xl:✅ Runtime viewport width check, not compile-time
Programmatic breakpoint queryBreakpoint::from_width(width) + reactive::get_viewport_width()🔶 No dedicated use_breakpoint() hook; must combine manually
CSS Container QueriesNo equivalent

css! macro does not support @media; responsive capabilities are limited to tw! utilities. The breakpoint.rs module doc-comment mentions a use_breakpoint() hook, but that function is not implemented or exported. The actual approach is to combine Breakpoint::from_width() with reactive::get_viewport_width() to obtain the current breakpoint.

5. Input Controls

HTML input typevgui SupportDifferences
text/password/search/email/url/telFull-featured text input (cursor/selection/clipboard/IME)
numberText input + numeric validation + min/max/step
date/datetime-local/time/month/weekCalendar popup
colorPreset color palette popup🔶 No free RGB sliders
checkbox18×18 rounded box + ✓
radioroving tabindex (arrow key navigation within radiogroup)
rangeDraggable slider
fileFile picker🔶 multiple supported, accept unused
submit/button/resetClickable button✅ Auto-binds on:submit/on:reset inside form
hiddenEmpty render
textareaMulti-line text inputrows attribute
selectDropdown popup✅ options/groups/multiple/custom option content rendering
datalistAutocomplete suggestionslist=id association
labelfor= explicit association / wrapping association
formon:submit/on:reset + Enter to submit
input type="image" / standalone <button> element / <output>🔶 <output> is only a div alias; no image input

6. Event System

HTML Eventvgui EventSupport
clickon:click✅ Also click() helper function for simplified signature
keydown / keyupon:keydown / on:keyup
pointerdown / pointerup / pointermoveon:pointerdown/up/move
dblclickon:dblclick
contextmenuon:contextmenu
scroll / wheelon:scroll / on:wheel
resizeon:resize
modifiers_changedon:modifiers_changed
mouse_down_out / mouse_up_outon:mouse_down_out / on:mouse_up_out✅ gpui-specific
any_mouse_downon:any_mouse_down✅ gpui-specific
input / changeon:input / on:change✅ Signature varies by input type
submit / reseton:submit / on:reset✅ Form context
closeon:close✅ Dialog-specific
focus/blur/mouseenter/mouseleave/load/Drag events/touch eventsgpui pointer events cover some

7. Accessibility (ARIA)

HTML ARIAvguiSupport
role attributerole="button" etc., maps to accesskit::Role
aria:labelaria:label="..."
aria:description aria:keyshortcuts aria:selected aria:expanded
aria:toggled aria:valuenow/aria:numeric_value aria:value aria:placeholder
aria:numeric_value_step
role/aria:* on input/select/textarea/label/formMust use wrapping element🔶
HTML native a11y (alt attribute)img alt accepted but not used for a11y🔶
accesskit integrationgpui exposes accessibility tree via accesskit

8. State Management & Reactivity

Web Framework PatternvguiSupport
React useStatecreate_signal (SolidJS-style fine-grained)
React useMemocreate_memo
React useEffectcreate_effect (synchronous execution, not async)
React useEffect cleanupon_cleanup (runs on scope disposal)
Dependency trackingAutomatic fine-grained tracking
React Context APIContext<T> + <Provider> + use_context
Re-runs entire app() closure on each renderNot SolidJS compile-time fine-grained, but slot model keeps state persistent🔶
useReducer / useRefUse signal + NodeRef as replacement🔶 No direct equivalent

9. Overlays & Popups

HTML ElementvguiSupport
<dialog> (HTML5)<dialog>✅ Portal rendering, focus trap, focus restore, click-outside, Escape to close
Custom modal/portal<portal priority={N}>
tooltip/popover positioning<floating position={...}>✅ Automatic overflow avoidance
<details>/<summary><details open={}> + <summary>open is a controlled prop
<progress><progress value={} max={}>
<meter><meter value={} min={} max={} low={} high={} optimum={}>

10. Canvas 2D Drawing

HTML Canvas APIvgui Context2DSupport
fillRect / strokeRectfill_rect / stroke_rect
clearRectclear_rect🔶 No-op (immediate mode; repaints from blank each frame)
beginPath / moveTo / lineTo / closePathbegin_path / move_to / line_to / close_path
quadraticCurveTo / bezierCurveToquadratic_curve_to / bezier_curve_to
arcarc✅ Flattened to line segments (max(2, ceil(sweep/(π/16))))
fill / strokefill / stroke
fillTextfill_texty is baseline; parses CSS font shorthand
strokeTextstroke_text❌ No-op (gpui has no text outline API)
measureTextmeasure_textTextMetrics { width }
save / restoresave / restore
translate / rotate / scaletranslate / rotate / scale
setTransform / resetTransformset_transform / reset_transform
fillStyle / strokeStylefill_style / stroke_style (Hsla)🔶 Solid colors only; no gradients/patterns
lineWidthline_width
fontfont (CSS shorthand, e.g. "16px sans-serif")
textAligntext_align (CanvasTextAlign)Start/Left/Center/Right/End
globalAlphaglobal_alpha✅ Clamped 0–1
lineCap / lineJoin❌ lyon types not re-exported from gpui
createLinearGradient / createRadialGradient / createPattern❌ No gradient/pattern support
drawImage❌ Requires RenderImage (not exposed)
clip❌ gpui content mask is axis-aligned bounds only
putImageData / getImageData / createImageData❌ No pixel-level access
shadowBlur / shadowColor / shadowOffset*❌ No shadow on paths (use box-shadow on wrapping element)
Canvas events (mousemove, click on canvas)🔶 Wrap <canvas> in <div on:click={...}>

Runtime CSS color parser color() supports #rgb/#rgba/#rrggbb/#rrggbbaa, rgb(), rgba(), hsl(), hsla(), 140+ named colors, and "transparent".

See Canvas Example for a live demo.

11. Routing

HTML/CSSvguiSupport
No native SPA routing (relies on frameworks)router module

API overview:

  • create_router(initial)Router (signal-driven)
  • match_pattern("/users/:id", path)RouteMatch with params
  • :param placeholder + * wildcard
  • build_path(pattern, params) reverse building
  • router.navigate(cx, path) navigation
  • router.render(cx, &[(pattern, callback)]) declarative route matching

See Router for details.

12. Refs & Imperative Operations

HTML DOM ref / React refvgui NodeRefSupport
focus / is_focused / contains_focused
bounds / scroll_offset / scroll_to / scroll_to_top / scroll_to_bottom / set_scroll_offset
child_bounds / child_count
Full DOM handleBased on gpui FocusHandle + ScrollHandle; data from previous frame🔶
Direct ref on select/textarea/text input/rangeUse wrapping div🔶

13. Theming System

HTML/CSSvguiSupport
CSS Custom Properties (--var)--name: value + var(--name) inside css!
CSS theme switchingtheme! macro + set_theme() global thread-local
Automatic reactive theme changesMust read signal inside render closure + set_theme to trigger re-render🔶
CSS media query theme switchingCombine breakpoint + signal manually🔶

14. Cross-Platform

HTML/CSSvguiSupport
Cross-platform (browser)Native + WASM dual-target
  • Linux: Wayland/X11
  • macOS: Cocoa/Metal
  • Windows: Win32/DirectX
  • Web: wasm32-unknown-unknown + wasm-bindgen
  • GPU-accelerated rendering (gpui)

15. Unsupported Features Summary

FeatureAlternative
transform / translate / rotate / scale❌ None
transition / animation (inside css!)tw! transition-* / animate-*
z-index (inside css!)tw! z-N
box-sizing❌ None (content-box semantics)
outlineborder
list-style / list-style-typeManually draw numbers/bullets
background-imagelinear-gradient
background-position / background-size / background-repeat❌ None
float / clear❌ None
@media queries (inside css!)tw! responsive prefixes sm:/md:/lg:/xl:
!important❌ None
position: fixed / sticky❌ None
animate-spin❌ None (no rotation transform)
:focus-within / :focus-visible / :visited / :link / :target / :nth-child etc.❌ None
CSS Container Queries❌ None
focus / blur / mouseenter / mouseleave / load / Drag / touch eventsgpui pointer events cover some
Grid fr / minmax() / repeat() / auto-fit / auto-fill / grid-auto-*Numeric column counts
rowspan visual effect❌ None
input type="image"❌ None
Canvas strokeText / drawImage / clip / gradients / patterns / pixel ops❌ See Canvas 2D Drawing for details
Canvas lineCap / lineJoin❌ lyon types not re-exported

16. Documentation vs Source Consistency Notes

This section summarizes the alignment between book documentation and actual source code, for developer reference.

Already-documented features (this page aggregates and compares them; they are not new discoveries):

Source vs documentation discrepancies:

  • use_breakpoint() hook: The crates/vgui/src/breakpoint.rs module doc-comment (line 3) mentions a use_breakpoint() hook, but that function is neither implemented nor exported. The actual approach to obtain the current breakpoint is to combine Breakpoint::from_width() with reactive::get_viewport_width(). The book only documents the tw! responsive prefixes and does not cover this programmatic API.
  • css! does not support @media: CSS Property Reference lists @media as an unsupported css! property and points to tw! responsive prefixes. This description is accurate — the css! macro itself does not support media queries; responsive capabilities are provided by tw!.

API Reference

The API reference is generated automatically by rustdoc. The links below point to each crate’s generated documentation:

vgui

The main crate. Key exports:

Macros

MacroSource crateDescription
view!vgui-viewJSX-like view markup.
css!vgui-cssCSS-in-Rust style declarations.
tw!vgui-tailwindTailwind utility class compilation.
variants!vguiComponent variant system (base + dimension styles).
theme!vguiCSS variable theme builder.
twc!vguiDynamic Tailwind class composition at runtime.

Reactivity

ItemDescription
create_signal&lt;T&gt;(initial) -> (ReadSignal&lt;T&gt;, WriteSignal&lt;T&gt;)Creates a reactive signal.
create_memo&lt;T&gt;(f) -> ReadSignal&lt;T&gt;Creates a derived, cached value.
create_effect(f)Creates a side effect that re-runs on dep change.
on_cleanup(f)Registers a cleanup callback that runs on scope disposal.
ReadSignal&lt;T&gt;::get() -> TReads value, registers dependency.
ReadSignal&lt;T&gt;::get_with(cx) -> TReads value without tracking.
WriteSignal&lt;T&gt;::set(cx, value)Sets value, notifies if changed.
WriteSignal&lt;T&gt;::update(cx, f) -> RMutates value in place, notifies if changed. Returns the closure’s result.
create_store&lt;T&gt;(initial) -> (Store&lt;T&gt;, SetStore&lt;T&gt;)Creates a reactive store for aggregate state. T: Clone + 'static (no PartialEq needed).
Store&lt;T&gt;::get() -> TReads whole state, registers dependency.
Store&lt;T&gt;::with(f) -> RBorrows state through a closure, registers dependency.
Store&lt;T&gt;::select(f) -> ReadSignal&lt;U&gt;Derives a fine-grained signal for a slice; only notifies when the slice changes.
SetStore&lt;T&gt;::set(cx, value)Replaces entire state, always notifies.
SetStore&lt;T&gt;::update(cx, f) -> RMutates state in place, always notifies.
next_auto_id() -> u64Stable per-render element id (used by view!).

Mounting

ItemDescription
mount(cx, render_fn) -> Entity<VguiRoot>Creates the root entity and reactive scope.
VguiRootThe gpui entity owning the reactive scope.

Router

ItemDescription
create_router(initial: &str) -> RouterCreates a router backed by a path signal.
Router::navigate(cx, path)Updates the path signal, triggering re-render.
Router::path() -> StringReactive read of current path.
Router::path_with(cx) -> StringNon-tracking read of current path.
Router::path_signal() -> ReadSignal<String>The underlying read signal.
Router::match_route(pattern) -> Option<RouteMatch>Match a single pattern against current path.
Router::render(cx, routes, fallback) -> ERender first matching route from &[(&str, F)].
RouteMatch{ pattern, path, params: HashMap<String, String> }.
match_pattern(pattern, path) -> Option<RouteMatch>Standalone pattern match with :param and * wildcard.
build_path(pattern, params) -> StringSubstitute :param placeholders with values.

See Router for a full guide.

Context

ItemDescription
Context&lt;T&gt;Zero-sized typed marker for a context key.
use_context(&Context&lt;T&gt;) -> Option&lt;T&gt;Read nearest ancestor provider value.
use_context_or(&Context&lt;T&gt;, || T) -> TRead nearest provider, or fallback default.
provide_context(&Context&lt;T&gt;, value) -> ProviderGuardRAII manual provider (pops on drop).
ProviderGuardGuard returned by provide_context.

See Context & Provider for a full guide.

Control flow

ItemDescription
show(when, then, fallback) -> AnyElementConditional render with fallback.
show_when(when, then) -> AnyElementConditional render (no fallback).
for_each(items, child_fn) -> AnyElementList rendering.
for_each_or(items, fallback, child_fn) -> AnyElementList rendering with fallback.
index_list(items, child_fn) -> AnyElementKeyed-by-position list with per-item child scopes.
index_list_or(items, fallback, child_fn) -> AnyElementKeyed-by-position list with fallback.
progress(value, max) -> DivProgress bar.
meter(value, min, max, low, high, optimum) -> DivMeter gauge.
details(open, summary, content) -> AnyElementCollapsible container.

Overlays

ItemDescription
portal(content, priority) -> AnyElementPortal floating layer at a given priority.
dialog(open, on_close, content) -> AnyElementModal dialog with portal, click-outside, escape.
floating(position, content) -> AnyElementPositioned floating element.

Input widgets

ItemDescription
text_input(TextInputProps) -> Entity<TextInput>Text-based input.
text_area(TextAreaProps) -> Entity<TextInput>Multi-line text input.
checkbox(CheckboxProps) -> Stateful<Div>Checkbox widget.
radio(RadioProps) -> Stateful<Div>Radio button widget.
range_input(RangeProps) -> Entity<RangeInput>Range slider.
file_input(FileProps) -> Stateful<Div>File picker button.
select(SelectProps) -> Stateful<Div>Select dropdown.
select_with_options(SelectProps, R) -> Stateful<Div>Select with per-option content renderer closure.
datalistAutocomplete suggestions for text inputs (via <datalist> element).

Props structs

StructFields
TextInputPropskind, multiline, value, placeholder, disabled, readonly, min, max, step, on_input, on_change, style, class, id, tabindex
TextAreaPropsvalue, placeholder, disabled, readonly, on_input, on_change, style, class, id, tabindex
CheckboxPropschecked, disabled, on_change
RadioPropschecked, disabled, on_change
RangePropsvalue, min, max, step, disabled, on_change, style, class, id, tabindex
FilePropsmultiple, on_change
SelectPropsoptions: Vec<(String, String)>, groups: Vec<SelectGroup>, value, multiple, disabled, on_change

Enums

EnumVariants
TextKindText, Password, Search, Email, Url, Tel, Number, Date, DateTime, Time, Month, Week, Color
BreakpointSm, Md, Lg, Xl

Refs

ItemDescription
NodeRef::new()Creates a new NodeRef handle.
NodeRef::focus()Focuses the bound element.
NodeRef::scroll_to_bottom()Scrolls the bound element to the bottom.
NodeRef::bounds()Returns the bounds of the bound element.

See Refs & NodeRef for the full API.

Style types

ItemDescription
CssOutput of css!. Wraps a FnOnce(&mut StyleRefinement).
TwStyleOutput of tw!/twc!. Holds base, hover, focus, active closures.
ApplyStyle&lt;E&gt;Trait for applying a style to an element.
TwClassBuilder for composing Tailwind classes (new(), add(), add_if()).
TwClassSourceTrait implemented for &str, String, Option&lt;T&gt;, TwClass.
IntoTwStyleTrait converting class sources into TwStyle.
tw_dynamic(classes: &str)Runtime Tailwind class interpreter (counterpart to tw!).
TwAnimationAnimation definition type.
TwTransitionTransition definition type.
EasingEasing function enum for animations/transitions.
ThemeCSS variable theme store (built by theme!).
CssValueEnum of CSS value kinds (color, length, number, keyword).
set_theme(theme)Installs a theme into the thread-local store.
with_theme(theme, f)Runs a closure with a temporary theme.
Spread&lt;E&gt;Trait for spreading props onto a built-in element.

Helpers

ItemDescription
click(f)Wraps Fn(&mut App) into a gpui click handler.
into_child(value)Converts any IntoElement (or IntoViewChild) to AnyElement.
input_cb(f)Wraps FnMut(&str, &mut App) for on:input.
str_change_cb(f)Wraps FnMut(&str, &mut App) for on:change (text).
bool_change_cb(f)Wraps FnMut(bool, &mut App) for on:change (checkbox/radio).
f64_change_cb(f)Wraps FnMut(f64, &mut App) for on:change (range).
files_cb(f)Wraps FnMut(Vec<PathBuf>, &mut App) for on:change (file).
str_select_change_cb(f)Wraps FnMut(&str, &mut App) for on:change (select).
intercept_keyboard_events()Installs window-level keyboard event listeners (WASM-only; no-op on native). Required in every WASM start().

Prelude

use vgui::prelude::* brings into scope:

view, css, tw, twc, tw_dynamic, variants, theme, set_theme, with_theme, create_signal, create_memo, create_effect, create_store, create_router, on_cleanup, index_list, index_list_or, enter_child_scope, exit_child_scope, ReadSignal, WriteSignal, Store, SetStore, RouteMatch, Router, Breakpoint, click, mount, Context, use_context, use_context_or, provide_context, NodeRef, KeyboardEvent, PointerEvent, PointerType, ResizeEvent, WheelEvent, checkbox, radio, range_input, file_input, input_cb, bool_change_cb, f64_change_cb, files_cb, CheckboxProps, FileProps, RadioProps, RangeProps, TextInputProps, TextKind, Theme, TwClass, TwClassSource, IntoTwStyle, portal, floating, and all of gpui::prelude::*.

vgui-view

The view! proc-macro crate. No runtime API — the macro expands at compile time.

vgui-css

The css! proc-macro crate. No runtime API — the macro expands at compile time.

vgui-tailwind

The tw! proc-macro crate. No runtime API — the macro expands at compile time.

vgui-tailwind-core

Shared library crate (not a proc-macro) providing the parse logic and class tables used by both tw! (compile-time) and tw_dynamic (runtime). Has no gpui dependency.

Building Locally

The entire documentation site — WASM demos, this mdBook, and the rustdoc API reference — is built with a single command:

scripts/build_docs.sh

The output is written to book/book/. To build and immediately serve it:

scripts/build_docs.sh --serve

The site is then available at http://127.0.0.1:8080.

Prerequisites: mdbook, wasm-bindgen, and the nightly Rust toolchain (the repo pins nightly via rust-toolchain.toml).

Contributing

Contributions to vgui are welcome! This guide covers the basics of getting set up for development.

Development Setup

Clone and build

git clone https://github.com/vgerbot-libraries/vgui.git
cd vgui
cargo build

The first build compiles gpui and its graphics backends, so expect a longer initial compile. See Installation for system library prerequisites.

Run the examples

The examples are the fastest way to verify your changes:

cargo run -p vgui-counter
cargo run -p vgui-todolist
cargo run -p vgui-styling
cargo run -p vgui-theming
cargo run -p vgui-variants
cargo run -p vgui-inputs
cargo run -p vgui-elements
cargo run -p vgui-forms
cargo run -p vgui-context
cargo run -p vgui-refs
cargo run -p vgui-focus
cargo run -p vgui-overlays
cargo run -p vgui-animation
cargo run -p vgui-canvas
cargo run -p vgui-router
cargo run -p vgui-dashboard

When adding a new example, follow the Example Writing Rule to ensure it supports both native and WASM targets with a live demo in the mdBook.

Run tests

cargo test --workspace

Integration tests live in crates/vgui/tests/. The element_id test suite verifies that auto-generated element ids are stable across re-renders.

Code Style

  • Follow standard rustfmt formatting. Run cargo fmt before committing.
  • Run cargo clippy --workspace and address warnings.
  • Keep public API items documented with /// doc comments — these appear in rustdoc.
  • Prefer the existing patterns in the codebase:
    • Proc-macro crates use hand-rolled token-tree parsing (no syn-based JSX parser).
    • Built-in elements map to gpui::div() with builder chains.
    • Reactivity follows the slot-index model (like React hooks).
  • When adding a new HTML element, add it to the emit_builtin match in crates/vgui-view/src/builtin.rs.
  • When adding a new CSS property, add it to the appropriate category module in crates/vgui-css/src/ (layout.rs, box_model.rs, visual.rs, or text.rs).
  • When adding a new Tailwind utility, add it to emit_exact or emit_prefixed in crates/vgui-tailwind/src/lib.rs.

Adding Documentation

The mdBook lives in book/src/. To preview changes:

cargo install mdbook
cd book
mdbook serve --open

When adding a new page, update book/src/SUMMARY.md to include it in the table of contents.

The book content should be grounded in the actual source code — verify API signatures, attribute lists, and behavior against the implementation before documenting them.

Reporting Issues

Report bugs and request features on the GitHub issue tracker.

When reporting a bug, please include:

  1. The Rust toolchain version (rustc --version).
  2. The OS and window system (e.g., Linux/Wayland, macOS, Windows).
  3. A minimal reproduction — the smallest view! snippet that triggers the issue.
  4. The expected behavior vs. actual behavior.
  5. Any relevant compiler output or runtime panics.

Since vgui is early-stage, breaking changes are expected between releases. If you are building against a specific commit, pin your dependency with a rev or tag specifier in Cargo.toml.