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 togpuistyle refinements. - Tailwind-style classes via the
tw!macro —class="p-2 rounded hover:bg-[#000088]"works out of the box, withhover:,focus:, andactive:variants. - Fine-grained reactivity inspired by SolidJS:
create_signal,create_memo, andcreate_effecttrack 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 attributes —
roleandaria:nameattributes 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).
- Overlays —
portal,dialog, andfloatingfor 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,Copyvariant structs. - Animations & transitions —
tw!animate-*andtransition-*utilities with easing functions and keyframe support. - Responsive breakpoints —
sm:,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 & theming —
theme!macro builds aThemeof CSS custom properties;var(--name)incss!resolves them at runtime;set_theme()enables reactive light/dark switching. - SPA router — signal-driven router with
:parampattern 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. libclangforbindgen(used bygpuiand 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
Application::new().run(...)— starts thegpuievent loop.cx.open_window(...)— opens a window with the given bounds.vgui::mount(window, cx, app)— creates aVguiRootentity that owns the reactive scope and callsapp()on every render.create_signal(0i32)— creates a reactive signal holding the count. Returns a(ReadSignal, WriteSignal)pair.view! { ... }— expands the JSX-like markup intogpuielement builders at compile time.count.get()— reads the signal value and registers the current scope as a dependency, so this<span>re-renders when the count changes.click(move |cx| set_count.update(cx, |n| *n += 1))— theclickhelper wraps a closure into the event handler signaturegpuiexpects.set_count.updatemutates 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:
| Crate | Kind | Description |
|---|---|---|
vgui | lib | The main crate: reactivity, root mounting, styling traits, widgets. |
vgui-view | proc-macro | The view! macro — JSX-like syntax parser and code generator. |
vgui-css | proc-macro | The css! macro — CSS declaration parser. |
vgui-tailwind | proc-macro | The tw! macro and the Tailwind class registry. |
vgui-tailwind-core | lib | Shared class-parse/tables for tw! and tw_dynamic (no gpui dep). |
In addition, sixteen example binaries live under examples/:
| Example | Package name | Demonstrates |
|---|---|---|
| Counter | vgui-counter | Signals, create_memo, <Show>, twc! class composition. |
| Todo List | vgui-todolist | <For> with fallback, css! styling, filtering, CRUD. |
| Styling Showcase | vgui-styling | css! macro, Tailwind classes, pseudo-states, twc!, responsive breakpoints. |
| Theming | vgui-theming | CSS variables, theme! macro, light/dark switching. |
| Component Variants | vgui-variants | variants! macro, typed variant structs, ApplyStyle. |
| Inputs | vgui-inputs | All <input> types, <select> with groups/multiple/custom rendering. |
| HTML Elements | vgui-elements | HTML tag coverage, tables, progress, details, dialog. |
| Forms | vgui-forms | <form> submission, reset, field grouping, enter-to-submit. |
| Context & Provider | vgui-context | Context API, <Provider>, use_context, multi-module. |
| Refs & NodeRef | vgui-refs | NodeRef imperative handles (focus, scroll, bounds). |
| Focus Management | vgui-focus | Focus trap, restore, roving tabindex, on:resize. |
| Overlays | vgui-overlays | portal(), dialog(), floating() overlay patterns. |
| Animation | vgui-animation | Animations, transitions, keyframes, custom animate={...}. |
| Canvas | vgui-canvas | <canvas>, Context2D API, shapes, paths, text, transforms. |
| Router | vgui-router | SPA router with param matching, navigation, wildcard routes. |
| Dashboard | vgui-dashboard | Capstone: router + theming + context + forms + overlays. |
Crate Dependencies
The dependency graph is straightforward:
vgui-view ──┐
vgui-css ──┼──► vgui ──► gpui
vgui-tailwind ─► vgui-tailwind-core
vguidepends ongpuiand re-exports the proc-macro crates (view,css,tw) viapub use.vgui-tailwinddepends onvgui-tailwind-corefor shared parse logic and class tables.- The proc-macro crates are completely independent of
vguiat the macro level — they emit::vgui::*and::gpui::*qualified paths, so they work in any crate that depends onvgui. - Application crates depend on both
vguiandgpui.
The vgui crate’s internal module structure:
| Module | Responsibility |
|---|---|
reactive | create_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. |
root | VguiRoot entity, Scope (the reactive owner), mount(). |
control | show, show_when, for_each, for_each_or, progress, meter, details. |
overlay | portal, floating, dialog (modal overlay with portal, click-outside, escape). |
input_text | TextInput widget (text fields, text areas), TextInputProps, TextAreaProps, TextKind. |
input_widgets | checkbox, radio, range_input, file_input, select and their props. |
label | Label-to-input focus association via for= / wrapping. |
style | Css, TwStyle, ApplyStyle trait. |
child | IntoViewChild trait, into_child, click helper. |
router | SPA router: create_router, Router, match_pattern, build_path, RouteMatch. |
context | Context API: Context<T>, use_context, use_context_or, provide_context, ProviderGuard. |
ref_handle | NodeRef imperative handle (focus, scroll, bounds). |
aria | ARIA role and attribute resolution (__resolve_aria_role). |
animation | Animation and transition types (TwAnimation, TwTransition, Easing). |
theme | CSS variable theming: Theme, CssValue, set_theme, with_theme. |
spread | Spread<E> trait for spread attributes on built-in elements. |
breakpoint | Responsive breakpoint detection (Breakpoint, __apply_breakpoint_styles). |
grid_areas | grid-template-areas and grid-area CSS property support. |
form | Form context: on:submit/on:reset, child submit/reset button dispatch. |
web | WASM helpers: intercept_keyboard_events(). |
tw_dynamic | Runtime Tailwind class interpreter (tw_dynamic). |
prelude | Re-exports the common API surface + gpui::prelude::*. |
How Rendering Works
The render cycle
-
vgui::mount(window, cx, app)creates aVguiRootgpui entity.VguiRootholds aScope(the reactive owner) and aBox<dyn FnMut() -> AnyElement>closure wrapping the user’sapp()function. -
On every render,
VguiRoot::render:- Resets
scope.indexto 0 on the root scope and all descendant child scopes (created by<Switch>/<Index>/routes) — this is the per-render slot counter that givescreate_signal/create_memo/create_effect/on_cleanuptheir stable identity across re-renders (like React’s hooks rules). - Calls
enter_scopeto set the current scope andgpui::Contextin a thread-local. - Calls
(self.render)()— the user’sapp()function — which callscreate_signal,view!, etc. - Calls
exit_scopeto clear the thread-local. - Wraps the result in a
gpui::div()with aTab/Shift+Tabkey handler for focus cycling.
- Resets
-
Inside
app(), calls tocreate_signal/create_memo/create_effectare resolved by slot index. On the first render, a new signal/memo/effect is created and stored inscope.slots[index]. On subsequent renders, the existing slot is reused — the signal’s value persists across re-renders. -
view!expansion producesgpuielement builder expressions. Each element is agpui::div()(orgpui::img(),gpui::svg(), or a vgui widget constructor) with.child()chains,.style()mutations, and event handlers attached. -
When a signal changes via
WriteSignal::setorWriteSignal::update,gpuinotifies theVguiRootentity, which callsnotify_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 callscx.notify()to trigger a re-render of theVguiRootentity — which re-runsapp()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:
| Node | Syntax | Expands 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:
| Form | Example | Notes |
|---|---|---|
| String literal | class="flex p-4" | Parsed at compile time where relevant. |
| Expression | on:click={expr} | Any Rust expression in braces. |
| Boolean literal | disabled={true} | true / false literals. |
| Integer literal | tabindex={0} | Numeric literal or {expr}. |
Attribute categories
| Attribute | Syntax | Applies to | Effect |
|---|---|---|---|
style | style={css!{...}} | All elements | Applies CSS-in-Rust styles. |
class | class="..." | All elements | Expands via tw! to Tailwind utilities. |
hover | hover={css!{...}} | All elements | Pseudo-state style on hover. |
active | active={css!{...}} | All elements | Pseudo-state style on mouse-down. |
focus | focus={css!{...}} | All elements | Pseudo-state style on focus. |
id | id="my-id" | All elements | Sets the gpui element id. |
tabindex | tabindex={0} | All elements | ≥0 sets tab order; <0 is focusable. |
on:event | on:click={handler} | All elements | Attaches an event handler. |
type | type="text" | <input> only | Selects the input widget kind. |
src | src={path} | <img>, <svg> only | Image/SVG path. |
for | for="id" | <label> only | Associates label with input by id. |
ref | ref={node_ref} | All elements | Binds a NodeRef handle for imperative ops (focus, scroll, bounds). |
role | role="button" | All elements | Sets ARIA role via __resolve_aria_role. |
aria:name | aria:label="..." | All elements | 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). |
Event handlers
Events use the on:event={handler} syntax. Supported events:
| Event | Handler signature |
|---|---|
on:click | Fn(&ClickEvent, &mut Window, &mut App) |
on:keydown | Fn(&KeyboardEvent, &mut Window, &mut App) |
on:keyup | Fn(&KeyboardEvent, &mut Window, &mut App) |
on:pointerdown | Fn(&PointerEvent, &mut Window, &mut App) |
on:pointerup | Fn(&PointerEvent, &mut Window, &mut App) |
on:pointermove | Fn(&PointerEvent, &mut Window, &mut App) |
on:resize | Fn(&ResizeEvent, &mut Window, &mut App) |
on:scroll | Fn(&ScrollWheelEvent, &mut Window, &mut App) |
on:wheel | Fn(&WheelEvent, &mut Window, &mut App) |
on:dblclick | Fn(&PointerEvent, &mut Window, &mut App) |
on:contextmenu | Fn(&PointerEvent, &mut Window, &mut App) |
on:modifiers_changed | Fn(&ModifiersChangedEvent, &mut Window, &mut App) |
on:mouse_down_out | Fn(&MouseDownEvent, &mut Window, &mut App) |
on:mouse_up_out | Fn(&MouseUpEvent, &mut Window, &mut App) |
on:any_mouse_down | Fn(&MouseDownEvent, &mut Window, &mut App) |
For <img> only, two additional events are available:
| Event | Handler signature |
|---|---|
on:load | Fn(&mut App) |
on:error | Fn(&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:
| Event | Handler signature (text-based) | Handler signature (checkbox/radio) | Handler signature (range) | Handler signature (file) |
|---|---|---|---|---|
on:input | FnMut(&str, &mut App) | — | — | — |
on:change | FnMut(&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/focus→E = 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_signalmust be called insideapp()(or a function called fromapp()during render). It panics if noVguiRootscope 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
). Updatingnamedoes **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_signal | create_store | |
|---|---|---|
| Best for | Single flat value | Aggregate state tree |
PartialEq on T | Required | Not required |
| Write notifies | Only if value changed | Always |
| Fine-grained | N/A (single value) | Via select (lens closures) |
| Slot-cached | Yes | Yes |
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_cleanupis a no-op when no reactive scope is active (e.g. in standalone tests), so it can be safely called in testview!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("/");
// ...
}
}
Navigation
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
| Method | Description |
|---|---|
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>:
:paramsegments capture a single path segment intoparams.- 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:
| Field | Type | Description |
|---|---|---|
pattern | String | The pattern that matched (e.g. /users/:id). |
path | String | The actual path that was matched (e.g. /users/42). |
params | HashMap<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", ¶ms), "/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
| Syntax | Example | Maps to |
|---|---|---|
Npx | 8px | gpui::px(8.0) |
Nrem | 1.5rem | gpui::rems(1.5) |
N% | 50% | gpui::relative(0.5) |
auto | auto | gpui::Length::Auto |
bare N | 8 | gpui::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
| Syntax | Example | Maps to |
|---|---|---|
Hex #rgb | #fff | gpui::rgb(0xffffff) |
Hex #rrggbb | #ff0000 | gpui::rgb(0xff0000) |
Hex #rrggbbaa | #0000ff80 | gpui::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) |
| Named | red | gpui::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:
| Property | Expected type |
|---|---|
width, height | impl Into<gpui::Length> |
min-width, min-height | impl Into<gpui::Length> |
max-width, max-height | impl Into<gpui::Length> |
flex-grow, flex-shrink | f32 (cast) |
flex-basis | impl Into<gpui::Length> |
gap | impl Into<gpui::DefiniteLength> |
grid-template-columns/rows | u16 (cast) |
aspect-ratio | f32 (cast) |
opacity | f32 (cast) |
background / background-color | impl Into<gpui::Hsla> |
color | impl Into<gpui::Hsla> |
font-weight | gpui::FontWeight |
font-size | impl Into<gpui::DefiniteLength> |
line-height | impl Into<gpui::DefiniteLength> |
font-family | impl 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:
| Prefix | State | Example |
|---|---|---|
hover: | Mouse hover | hover:bg-[#000088] |
focus: | Keyboard focus | focus:border-blue-500 |
active: | Mouse down | active: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:
| Prefix | Min width | Example |
|---|---|---|
sm: | ≥ 640px | sm:flex-row |
md: | ≥ 768px | md:flex-row |
lg: | ≥ 1024px | lg:text-lg |
xl: | ≥ 1280px | xl: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:
| Category | Syntax | Example |
|---|---|---|
| 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
| Class | Effect |
|---|---|
flex | display: flex |
block | display: block |
hidden | display: none |
grid | display: grid |
inline-flex | display: 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
| Class | Effect |
|---|---|
flex-1 | grow=1, shrink=1, basis=0 |
flex-auto | grow=1, shrink=1, basis=auto |
flex-none | grow=0, shrink=0, basis=auto |
flex-grow | grow=1 |
flex-grow-0 | grow=0 |
flex-shrink | shrink=1 |
flex-shrink-0 | shrink=0 |
flex-grow-N | grow=N (arbitrary number) |
flex-shrink-N | shrink=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:
| Suffix | px | Suffix | px | Suffix | px |
|---|---|---|---|---|---|
0 | 0 | 4 | 16 | 12 | 48 |
px | 1 | 5 | 20 | 14 | 56 |
0.5 | 2 | 6 | 24 | 16 | 64 |
1 | 4 | 7 | 28 | 20 | 80 |
1.5 | 6 | 8 | 32 | 24 | 96 |
2 | 8 | 9 | 36 | 32 | 128 |
2.5 | 10 | 10 | 40 | 48 | 192 |
3 | 12 | 11 | 44 | 96 | 384 |
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
| Class | Effect |
|---|---|
w-full | width: 100% |
w-auto | width: auto |
w-fit | width: auto |
w-screen | width: 100% |
h-full | height: 100% |
h-auto | height: auto |
h-fit | height: auto |
h-screen | height: 100% |
min-w-full | min-width: 100% |
min-w-auto | min-width: auto |
min-h-full | min-height: 100% |
min-h-auto | min-height: auto |
max-w-full | max-width: 100% |
max-w-none | max-width: auto |
max-h-full | max-height: 100% |
max-h-none | max-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 (typicallycss!{...}) 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:
| Type | Description |
|---|---|
enum ButtonVariant { Primary, Secondary, Danger } | One enum per dimension; name = {Component}{Dimension} in PascalCase. |
enum ButtonSize { Sm, Md, Lg } | Option names are PascalCased (sm → Sm). |
struct ButtonVariants { pub variant: ButtonVariant, pub size: ButtonSize } | Combined Copy struct; name = {Component}Variants. |
impl Default for ButtonVariants | Selects the first option in each dimension. |
impl ButtonVariants { fn variant(..), fn size(..) } | Builder methods (one per dimension, consuming self). |
impl ApplyStyle<E> for ButtonVariants | Applies 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 thebase => ...line; theApplyStyleimpl applies only the dimension styles. - No dimensions (only
base): generates a unit structButtonVariants;whoseApplyStyleapplies 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#typefor the field/ method,r#Typefor 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
| Property | Values |
|---|---|
display | flex | block | none | grid |
visibility | hidden | visible |
overflow | hidden | scroll | visible | clip | auto |
overflow-x | hidden | scroll | visible | clip | auto |
overflow-y | hidden | scroll | visible | clip | auto |
position | relative | absolute |
flex-direction | row | column | row-reverse | column-reverse |
flex-wrap | nowrap | wrap | wrap-reverse |
flex | none | auto | N (grow) | N N (grow shrink, basis 0px) | N N basis |
flex-grow | number |
flex-shrink | number |
flex-basis | length |
justify-content | flex-start | flex-end | center | space-between | space-around | space-evenly |
align-items | flex-start | flex-end | center | baseline | stretch |
align-self | same as align-items |
align-content | flex-start | flex-end | center | space-between | space-around | stretch | space-evenly |
gap | length | length length |
row-gap | length |
column-gap | length |
grid-template-columns | number (column count) |
grid-template-rows | number (row count) |
grid-column | span N | A / B | N |
grid-column-start | number |
grid-column-end | number |
grid-row | span N | A / B | N |
grid-row-start | number |
grid-row-end | number |
grid-template-areas | string 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-width | auto | thin | none |
Box Model
| Property | Values |
|---|---|
width | length |
height | length |
min-width | length |
min-height | length |
max-width | length |
max-height | length |
padding | length | length length | length length length length |
padding-top | length |
padding-right | length |
padding-bottom | length |
padding-left | length |
padding-inline | length (sets left + right) |
padding-block | length (sets top + bottom) |
margin | length | length length | length length length length |
margin-top | length |
margin-right | length |
margin-bottom | length |
margin-left | length |
margin-inline | length (sets left + right) |
margin-block | length (sets top + bottom) |
inset | length | length length | length length length length |
top | length |
right | length |
bottom | length |
left | length |
aspect-ratio | number |
Visual
| Property | Values |
|---|---|
background | color | linear-gradient(angle, from, to) |
background-color | color |
color | color |
opacity | number (0.0–1.0) |
border | width style color (e.g. 1px solid #ccc) |
border-top | width style color (one side) |
border-right | width style color (one side) |
border-bottom | width style color (one side) |
border-left | width style color (one side) |
border-color | color |
border-style | solid | dashed |
border-width | length |
border-top-width | length |
border-right-width | length |
border-bottom-width | length |
border-left-width | length |
border-radius | length |
border-top-left-radius | length |
border-top-right-radius | length |
border-bottom-right-radius | length |
border-bottom-left-radius | length |
cursor | pointer | default | text | crosshair | not-allowed | grab | grabbing |
box-shadow | none | sm | md | lg | xl | [inset]? <offset-x> <offset-y> <blur>? <spread>? <color>? |
Text
| Property | Values |
|---|---|
font-size | length (not %) |
font-weight | thin | extra-light | light | normal | medium | semibold | bold | extrabold | black | 100–900 |
font-style | italic | normal |
font-family | string literal or keyword |
text-align | left | center | right |
text-decoration | underline | line-through | none |
text-decoration-color | color |
text-decoration-thickness | length |
text-decoration-style | solid | wavy |
text-overflow | ellipsis | clip |
text-background | color |
text-background-color | color |
white-space | nowrap | normal |
line-height | number (unitless → relative) or length |
line-clamp | number |
Unsupported Properties
The following common CSS properties are not supported by css! because
gpui’s styling model does not provide equivalents:
transform/translate/rotate/scaletransition/animation— usetw!transition-*/animate-*utilities instead. See Animations & Transitions.z-index(usetw!z-Nutilities instead)box-sizing(gpui uses content-box semantics)gapwith more than 2 valuesoutline(useborderinstead)list-style/list-style-typebackground-image(uselinear-gradientinbackgroundinstead)background-position/background-size/background-repeatfloat/clear@mediaqueries / responsive breakpoints — usetw!responsive prefixessm:/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:
| Class | Effect |
|---|---|
animate-pulse | Opacity fades between 1.0 and 0.5 (2 s loop). |
animate-bounce | Vertical margin offset ±8 px (1 s loop). |
animate-ping | Opacity 1.0 → 0 with slight scale (1 s loop). |
animate-spin | No-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:
| Class | Properties interpolated |
|---|---|
transition | opacity, color, background, margin, padding |
transition-opacity | opacity only |
transition-colors | text color, background |
transition-all | all 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
| Modifier | Example | Effect |
|---|---|---|
duration-* | duration-300 | Transition duration in ms. |
ease-* | ease-in-out | Easing function (linear, ease-in, ease-out, ease-in-out, ease-bounce). |
delay-* | delay-100 | Stored 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
- Compile time — the
tw!proc-macro parsesanimate-*,transition-*,duration-*,ease-*, anddelay-*classes and emitsTwAnimation/TwTransitionstructs insideTwStyle. view!macro — when aclassattribute is present, the macro destructuresTwStyleand callsapply_animation/apply_transitionafter children are attached.apply_transition— creates a hover signal, registerson_hover, and uses gpui’swith_animationto interpolate between the base and hoverStyleRefinementsnapshots.
Limitations
animate-spinis a no-op — gpui does not support CSS transforms/rotation ondivelements.- Animation + transition on the same element — when both are present,
animation takes priority. gpui’s
AnimationElementdoes not implementStyled, so a transition (whose animator needsStyled) 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
| Tag | Default 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
| Tag | Default 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.
Link Elements
| Tag | Default 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
| Tag | Attributes | Behavior |
|---|---|---|
<img> | src (required), object_fit, alt, on:load, on:error | gpui::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
| Tag | Behavior |
|---|---|
<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:
| Tag | Layout 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:
| Attribute | Syntax | Description |
|---|---|---|
style | style={css!{...}} | CSS-in-Rust styles. |
class | class="..." | Tailwind utility classes (expanded via tw!). |
hover | hover={css!{...}} | Styles applied on mouse hover. |
active | active={css!{...}} | Styles applied on mouse-down. |
focus | focus={css!{...}} | Styles applied on keyboard focus. |
id | id="my-id" | Sets the gpui element id. |
tabindex | tabindex={0} | ≥0 sets tab order; <0 is focusable only. |
on:event | on:click={handler} | Event handler (see view! macro). |
ref | ref={node_ref} | Binds a NodeRef handle for imperative ops (focus, scroll, bounds). See Refs. |
role | role="button" | Sets ARIA role via __resolve_aria_role. |
aria:name | aria: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
| Attribute | Type | Required | Description |
|---|---|---|---|
when | bool | Yes | Condition expression. |
fallback | element | No | Element 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
| Attribute | Type | Required | Description |
|---|---|---|---|
each | impl IntoIterator | Yes | The collection to iterate. |
fallback | element | No | Element 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_effectcalls 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_cleanupcallbacks 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
| Element | Attribute | Type | Required | Description |
|---|---|---|---|---|
<Switch> | fallback | element | No | Element when no <Match> matches. |
<Match> | when | bool | Yes | Condition 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
| Attribute | Type | Required | Description |
|---|---|---|---|
each | impl IntoIterator | Yes | The collection to iterate. |
fallback | element | No | Element 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 scope | No (shared parent scope) | Yes (child scope per position) |
| State persistence | Via slot order in parent scope | Via dedicated child scope per position |
| Disposal on shrink | No | Yes (on_cleanup runs) |
| Use case | Simple lists, stateless items | Lists 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:
| Category | Types |
|---|---|
| Text-based | text, password, search, email, url, tel, number, date, datetime-local, time, month, week, color |
| Boolean | checkbox, radio |
| Numeric | range |
| File | file |
| Button | submit, button, reset |
| Hidden | hidden |
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
| Attribute | Applies to | Description |
|---|---|---|
value | All text-based | Initial/current value (string). |
placeholder | All text-based | Placeholder text. |
disabled | All text-based | Disables input. |
readonly | All text-based | Read-only mode. |
min | number | Minimum numeric value (f64). |
max | number | Maximum numeric value (f64). |
step | number | Step increment (f64). |
on:input | All text-based | Fires on every keystroke. FnMut(&str, &mut App). |
on:change | All text-based | Fires on Enter/blur. FnMut(&str, &mut App). |
style | All text-based | CSS-in-Rust styles. |
class | All text-based | Tailwind classes. |
id | All text-based | Element id (for <label for=>). |
required | All text-based | Fails validation when empty. |
pattern | All text-based | Literal match or * wildcard (foo*, *bar). Not JS RegExp. |
minlength | All text-based | Minimum character count (usize). |
maxlength | All text-based | Maximum character count (usize). |
list | All text-based | Datalist id for autocomplete suggestions. |
Type-specific placeholder defaults
When no placeholder is specified, each type provides a format-appropriate
default:
| Type | Default placeholder |
|---|---|
text | (empty) |
password | (empty) |
search | Search… |
email | email@example.com |
url | https://example.com |
tel | +1 (555) 000-0000 |
number | 0 |
date | YYYY-MM-DD |
datetime-local | YYYY-MM-DDTHH:MM |
time | HH:MM |
month | YYYY-MM |
week | YYYY-Www |
color | #RRGGBB |
Note:
date,datetime-local,time,month, andweekrender a calendar popup.colorrenders a preset color palette popup.telis 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):
required— value must not be empty.minlength/maxlength— character count bounds.pattern— exact literal match or a single*wildcard prefix/suffix (foo*,*bar,foo). This is not a JavaScript RegExp.- Type-specific checks:
email— exactly one@with non-empty local and domain parts.url— must start withhttp://orhttps://.number— must parse asf64and satisfymin/maxif 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
| Attribute | Type | Description |
|---|---|---|
checked | bool | Checked state. |
disabled | bool | Disables input. |
on:change | FnMut(bool, &mut App) | Fires on click. New checked state. |
style/class/hover/active/focus/id/tabindex | — | Standard 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
| Attribute | Type | Default | Description |
|---|---|---|---|
min | f64 | 0.0 | Minimum value. |
max | f64 | 100.0 | Maximum value. |
step | f64 | 1.0 | Step increment. |
value | f64 | 0.0 | Current value. |
disabled | bool | false | Disables the slider. |
on:change | FnMut(f64, &mut App) | — | Fires on drag. New value. |
style/class/id/tabindex | — | — | Standard 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
| Attribute | Type | Description |
|---|---|---|
value | string | Button label text. |
multiple | bool | Allow multiple file selection. |
on:change | FnMut(Vec<PathBuf>, &mut App) | Fires on file selection. |
style/class/hover/active/focus/id/tabindex | — | Standard 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
| Attribute | Type | Description |
|---|---|---|
options | Vec<(String, String)> | Value-label pairs (flat list). |
groups | Vec<(String, Vec<(String, String)>)> | Grouped options; group name + value-label pairs. When non-empty, the popover renders by group. |
value | String | Currently selected value. In multiple mode, comma-separated values (a,b). |
multiple | bool | Enables multi-select. Default false. |
disabled | bool | Disables the select. |
on:change | FnMut(&str, &mut App) | Fires on selection change. In multiple mode, passes the comma-separated string. |
style/class/id | — | Standard 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
| Attribute | Type | Description |
|---|---|---|
value | String | Current content. |
placeholder | string | Placeholder text. |
disabled | bool | Disables editing. |
readonly | bool | Read-only mode. |
rows | u32 | Minimum visible line count. |
on:input | FnMut(&str, &mut App) | Fires on every keystroke. |
on:change | FnMut(&str, &mut App) | Fires on blur. |
style/class/id/tabindex | — | Standard 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>,submitandresetbuttons automatically invoke the form’son:submit/on:resethandler. An expliciton:clickoverrides 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
| Attribute | Type | Default | Description |
|---|---|---|---|
value | f64 | 0.0 | Current progress value. |
max | f64 | 1.0 | Maximum 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
| Attribute | Type | Default | Description |
|---|---|---|---|
value | f64 | 0.0 | Current value. |
min | f64 | 0.0 | Minimum of the range. |
max | f64 | 1.0 | Maximum of the range. |
low | Option<f64> | none | Lower bound of the “good” range. |
high | Option<f64> | none | Upper bound of the “good” range. |
optimum | Option<f64> | none | Presence (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
| Attribute | Type | Default | Description |
|---|---|---|---|
open | bool | false | Whether 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,
openis 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
| Attribute | Type | Default | Description |
|---|---|---|---|
open | bool | false | Whether the dialog is visible. |
on:close | Fn(&mut App) | no-op | Called 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:closehandler 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
| Attribute | Type | Default | Description |
|---|---|---|---|
priority | usize | 0 | Stacking 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
| Attribute | Type | Default | Description |
|---|---|---|---|
position | Point<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).
NodeRefwraps 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:
- Forces an auto-id if the element has no explicit
id(required fortrack_focus/track_scroll, which need aStatefulInteractiveElement). - Calls
__bind_refto cache aFocusHandle+ScrollHandlein the reactive scope slot, populating theNodeRefon first render and reusing them on subsequent renders. - Applies
track_focus(&handle)andtrack_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:
| Method | Signature | Description |
|---|---|---|
focus | (&self, &mut Window, &mut App) | Move keyboard focus to the element. |
is_focused | (&self, &Window) -> bool | Whether the element is currently focused. |
contains_focused | (&self, &Window, &App) -> bool | Whether 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) -> usize | Number of tracked children. |
focus_handle | (&self) -> FocusHandle | Clone the underlying focus handle. |
scroll_handle | (&self) -> ScrollHandle | Clone 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 returnEntity-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
| Function | Signature | Description |
|---|---|---|
use_context | (&Context<T>) -> Option<T> | Returns the nearest ancestor provider’s value, or None if no provider is active. |
use_context_or | (&Context<T>, || T) -> T | Like 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 syntax | Field name | Notes |
|---|---|---|
name={value} | name | Direct field assignment. |
on:click={h} | on_click | Event 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#type | Raw identifier (Rust keyword escaping). |
tabindex={0} | tabindex | |
for="id" | r#for | Raw identifier. |
ref={node_ref} | r#ref | Opt-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 theview!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_signalfor state management.create_memofor a derived value (doubled).<Show>for conditional rendering (positive/negative/zero/odd/even).- Component functions taking
ReadSignalandWriteSignalas props. twc!macro for dynamic Tailwind class composition.- Tailwind classes via
class=withhover: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_memofor a filtered list (visible_todos) and a derived count (remaining).<For>with afallbackfor list rendering.css!macro for styling (conditional styles based on state).hoverpseudo-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, andfocus. - Dynamic class composition with
twc!and conditionalOption<&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 inlinestyle. Any valid CSS property works, includinglinear-gradient,box-shadow, andrgba()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 syntaxbg-[#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: valuecustom property definitions insidetheme!.var(--name)references incss!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 thevariantdimension (primary, secondary, danger, outline).ButtonSize— enum for thesizedimension (sm, md, lg).ButtonVariants— aCopystruct that implementsApplyStyle, 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:inputhandlers. - Checkbox and radio inputs with
on:changehandlers. - Range slider with
on:changereturning anf64. - File picker with
on:changereturningVec<PathBuf>. <select>with grouped options via thegroupsprop.- Multiple select with comma-separated values.
<select>with a custom child closure for per-option rendering.tabindexfor focus ordering.- Label association via the
forattribute 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 type | Event | Handler value type | Example |
|---|---|---|---|
text / password / number / date | on:input | &str | move |v: &str, cx: &mut App| set_text.set(cx, v.to_string()) |
checkbox / radio | on:change | bool | move |v: bool, cx: &mut App| set_checked.set(cx, v) |
range | on:change | f64 | move |v: f64, cx: &mut App| set_slider.set(cx, v) |
file | on:change | Vec<PathBuf> | move |paths: Vec<PathBuf>, _cx: &mut App| { ... } |
select | on:change | &str | move |v: &str, cx: &mut App| set_sel.set(cx, v.to_string()) |
submit | on: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:
forattribute —<label for="username">references an input by itsid="username". Clicking the label focuses the input.- 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:
h1–h6headings.- Text formatting:
strong,em,u,s,mark,code,small. - Lists:
ul,ol,dl(withdt/dd). - Semantic tags:
header,nav,main,section,article,aside,footer. - Links via
<a>withon:click. - CSS properties applied through the
css!macro. - Tailwind utility classes for text overflow, font families, leading, and decoration.
progressandmetergauges.textareawithon:input.selectwithoptionsandon:change.details/summarytoggling.dialogwithopenandon:close.<img>withon:load/on:errorevent 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 anon:clickhandler; the example wires it to a no-op closure demonstrating the pattern. - Tables —
<table>with<thead>/<tbody>/<tr>/<th>/<td>. Thecolspan={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>— Theopenprop is bound to a signal. Clicking the<summary>toggles the signal viaon:click, which reactively opens or closes the hidden content block.<dialog>— Theopenprop is signal-driven. Theon:closehandler fires when the dialog is dismissed (Escape key or click-outside), resetting the signal tofalse. 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 takesFn(&mut App)(no event payload).on:error— fires when the image source fails to load. Same signature.
The example demonstrates both:
- A
RenderImagesource (a tiny 4×4 solid-color rectangle built with theimagecrate) fireson:loadon 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 triggerson: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>withon:submitandon:resethandlers.- Text, email, and number
<input>types withon:inputvalue binding. <select>with anoptionsvector of(value, label)pairs.- Checkbox
<input>with a booleanon:changehandler. - Submit and reset
<input>buttons. <Show>for conditional display of the submitted data.- Enter-to-submit: pressing Enter in a text input inside
<form>fireson: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:submitfires 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 aFormDatastruct, storing it in thesubmittedsignal.on:resetfires when the<input type="reset">button is clicked. It resets every field signal back to its default and clearssubmittedtoNone.
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 plainstatic, keyed byTypeId.<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()returningBounds<Pixels>withoriginandsizefrom 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/ResizeEventfor 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 fixedPoint<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::Emptyas anIntoElementfallback soshow()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-pingkeyframe animations.transition-opacityandtransition-colorswithduration-*/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_rectandstroke_rectwith different colors and line widths.- Path drawing:
begin_path,move_to,line_to,arc,close_path,fill,stroke. fill_textwith font setting and text alignment.save/restorewithtranslate/rotatefor 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(returnsTextMetrics).stroke_textis 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 (wherecx: &mut Appis available).router.match_route(pattern)— reactively readsrouter.path()and returnsOption<RouteMatch>, matching static segments and named params like/users/:id.RouteMatch::params— aHashMap<String, String>of extracted path parameters, used here to pull theidfrom/users/:id.- A cascade of
if let Some(m)blocks for route dispatch, falling through to a 404 view when no pattern matches. for_eachfor 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::params — HashMap<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.
navigate() in event handlers
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>(viafor_each) for todo list rendering.<Show>for conditional empty state and saved-settings feedback.create_memofor 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_storewrapping a singleCartStatestruct (items + discount + tax).Store::selectfor fine-grained selectors — each slice gets its ownReadSignalthat only updates when that slice changes.create_memolayered on top of selectors for derived computations (discount amount, after-discount, tax, total).SetStore::updatefor in-place partial mutations (qty, discount, tax).Store::withto 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_amount → after_discount → tax_amount → total
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
WriteSignalhandles. - The state type cannot or should not implement
PartialEq(e.g. containsVecs 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 is0the 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_cleanupcancels 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:
- Halts global dispatch —
VguiRootchecks the flag after each global handler and breaks out of the dispatch loop. - Stops gpui event bubbling — the
__dom_key_down/__dom_key_upwrappers callcx.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:
| Field | Type | Description |
|---|---|---|
key | String | The key value (e.g. "a", "Enter", "Shift") |
code | String | The physical key code (e.g. "KeyA", "Enter") |
ctrl_key | bool | Control modifier held |
shift_key | bool | Shift modifier held |
alt_key | bool | Alt modifier held |
meta_key | bool | Meta (Super/Command) modifier held |
repeat | bool | Key is auto-repeating |
key_char | Option<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:
| Combination | Meaning |
|---|---|
"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_default → stop_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
| Attribute | Required | Description |
|---|---|---|
count | Yes | usize — total number of items. |
render | Yes | Closure Fn(Range<usize>, &mut Window, &mut App) -> Vec<R> where R: IntoElement. |
scroll_handle | No | UniformListScrollHandle for programmatic scroll control. |
style | No | CSS-in-Rust style object (css! { ... }). |
class | No | Tailwind 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(notFnMut): gpui requires the closure to beFn. UseRc<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_signalinside the closure does not work — no reactive scope is active during prepaint. Create signals in theview!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_windowfor imperative window control (title, minimize, maximize, close).on:pointerdownwithstart_window_movefor a custom draggable titlebar.open_windowfor multi-window support.set_app_menusfor native menu bar.use_window_should_closefor close interception.WindowOptionswithTitlebarOptions,WindowDecorations::Client, andwindow_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(¤t_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 Elements | vgui Mapping | Notes |
|---|---|---|
div span p header footer nav main section article aside address form fieldset legend figure figcaption pre blockquote q | gpui::div() | No default styling, pure containers |
h1–h6 strong b em i u s del strike mark small code kbd samp var cite abbr dfn bdi bdo time | gpui::div() | With corresponding default font styles |
ul ol li dl dt dd | flex column / bold / padding-left | No list-style; must manually draw numbers/bullets |
a | div().cursor_pointer() + blue text | href attribute accepted but not navigable; use on:click for navigation |
button | div().cursor_pointer() | Default tabindex=0 |
img | gpui::img(src) | Supports object_fit (fill/contain/cover/scale-down/none); alt accepted but not used for a11y |
svg | gpui::svg().path(src) | — |
br hr wbr | empty line / separator / empty | void elements |
table thead tbody tfoot tr td th caption | flex layout simulation | colspan→flex_grow; rowspan has no visual effect |
colgroup col datalist option optgroup | compiles but renders empty or special-purpose | no-op elements |
canvas | vgui::canvas_element(paint) | <canvas paint={|ctx| ...}> with Context2D 2D drawing API; supports style/class, no children/events |
1.2 Custom Components
| HTML/CSS | vgui | Support |
|---|---|---|
| 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:click→on_click, type→r#type, ref→r#ref.
1.3 Control Flow
| HTML/CSS | vgui | Support |
|---|---|---|
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 Property | css! Support | Differences |
|---|---|---|
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-rows | ✅ | No fr unit; use numeric column counts |
grid-column grid-row (+-start/-end) | ✅ | — |
grid-template-areas | ✅ | String 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 Property | css! Support | Differences |
|---|---|---|
width height min-* max-* | ✅ | — |
padding (+-inline/-block) | ✅ | — |
margin (+-inline/-block) | ✅ | — |
inset top right bottom left | ✅ | — |
aspect-ratio | ✅ | — |
Visual
| HTML/CSS Property | css! Support | Differences |
|---|---|---|
background (color + linear-gradient) | ✅ | — |
background-color color opacity | ✅ | — |
border (+per-side/-color/-style/-width) | ✅ | — |
border-radius (+per-corner) | ✅ | — |
cursor box-shadow | ✅ | — |
Text
| HTML/CSS Property | css! Support | Differences |
|---|---|---|
font-size | ✅ | Does not support % |
font-weight (named + numeric 100–900) | ✅ | — |
font-style font-family | ✅ | — |
text-align | ✅ | left/center/right |
text-decoration (+-color/-thickness/-style) | ✅ | — |
text-overflow text-background white-space line-height line-clamp | ✅ | — |
CSS Variables & Interpolation
| Capability | Support | Differences |
|---|---|---|
--name: value definition | ✅ | Inside css! |
var(--name, fallback) reference | ✅ | — |
theme! macro + set_theme() | ✅ | Global thread-local theme |
{expr} runtime expression interpolation | ✅ | — |
2.2 Unsupported CSS Properties
| CSS Property | Reason | Alternative |
|---|---|---|
transform / translate / rotate / scale | gpui has no div transforms | ❌ None |
transition / animation (inside css!) | No equivalent in gpui styling model | Use tw! transition-*/animate-* classes |
z-index (inside css!) | No equivalent in gpui styling model | Use tw! z-N utilities |
box-sizing | gpui uses content-box semantics | ❌ None |
outline | No equivalent | Use border |
list-style / list-style-type | No list-style rendering | Manually draw numbers/bullets |
background-image | No equivalent | Use linear-gradient |
background-position / background-size / background-repeat | No equivalent | ❌ None |
float / clear | No equivalent | ❌ None |
@media queries (inside css!) | css! does not support media queries | Use tw! responsive prefixes sm:/md:/lg:/xl: |
!important | No equivalent | ❌ None |
position: fixed / sticky | gpui has no fixed/sticky positioning | ❌ None |
2.3 Tailwind Utilities (tw! Macro)
| Tailwind Category | Support | Differences |
|---|---|---|
Display: flex/block/hidden/grid/inline-flex | ✅ | inline-flex same as flex |
| Flex: direction/wrap/grow/shrink | ✅ | — |
| Justify / Align | ✅ | — |
Position: relative/absolute/static | ✅ | No fixed/sticky |
| Overflow | ✅ | — |
Spacing: p/m/gap full range | ✅ | spacing 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: sm–2xl/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-0–z-50/z-auto | ✅ | — |
Arbitrary values: [Npx]/[Nrem]/[N%]/[#hex]/[rgb()]/[rgba()] | ✅ | — |
Opacity modifier: /NN | ✅ | — |
2.4 Pseudo-States / Variants
| CSS Pseudo-Class | vgui Support | Differences |
|---|---|---|
:hover | hover={css!{}} attribute / hover: prefix | ✅ |
:focus | focus={css!{}} attribute / focus: prefix | ✅ |
:active | active={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):
| Prefix | Min Width |
|---|---|
sm: | ≥ 640px |
md: | ≥ 768px |
lg: | ≥ 1024px |
xl: | ≥ 1280px |
2.5 Animations & Transitions
| CSS animation/transition | vgui | Support |
|---|---|---|
@keyframes | Built-in animate-pulse/bounce/ping | ✅ |
animate-spin | gpui has no rotation transform | ❌ |
| Custom animations | animate={|el| el.with_animation(...)} closure | ✅ |
transition | transition/transition-opacity/transition-colors/transition-all | ✅ |
duration-* / ease-* | — | ✅ |
delay-* | Parsed and stored but no effect | 🔶 |
2.6 Component Variants System
| HTML/CSS | vgui | Support |
|---|---|---|
| No native variant system (relies on libraries like CVA) | variants! macro generates enum + composite struct + ApplyStyle impl | ✅ |
3. Layout Model
3.1 Flexbox
| HTML/CSS | vgui | Support |
|---|---|---|
| CSS flexbox full-featured | Based on gpui flexbox | ✅ |
flex-direction/wrap/grow/shrink/basis/justify/align/gap all fully supported.
3.2 Grid
| HTML/CSS Grid | vgui | Support |
|---|---|---|
grid-template-columns/rows (column/row counts) | ✅ | — |
grid-column/row (+-start/-end, span N) | ✅ | — |
grid-template-areas + grid-area | ✅ | thread-local stack parses named areas |
fr unit / minmax() / repeat() / auto-fit / auto-fill | ❌ | Use numeric column counts |
grid-auto-flow / grid-auto-columns / grid-auto-rows | ❌ | No equivalent |
3.3 Positioning
| HTML/CSS | vgui | Support |
|---|---|---|
position: relative / absolute | ✅ | — |
position: fixed / sticky | ❌ | No equivalent |
top/right/bottom/left/inset | ✅ | — |
3.4 Table Layout
| HTML/CSS | vgui | Support |
|---|---|---|
table semantic layout | flex simulation | 🔶 |
colspan | flex_grow | ✅ |
rowspan | No visual effect | ❌ |
| Automatic column width from content | Must manually specify w-[Npx] | 🔶 |
4. Responsive Design
| HTML/CSS | vgui | Support |
|---|---|---|
@media queries | tw! responsive prefixes sm:/md:/lg:/xl: | ✅ Runtime viewport width check, not compile-time |
| Programmatic breakpoint query | Breakpoint::from_width(width) + reactive::get_viewport_width() | 🔶 No dedicated use_breakpoint() hook; must combine manually |
| CSS Container Queries | ❌ | No equivalent |
css!macro does not support@media; responsive capabilities are limited totw!utilities. Thebreakpoint.rsmodule doc-comment mentions ause_breakpoint()hook, but that function is not implemented or exported. The actual approach is to combineBreakpoint::from_width()withreactive::get_viewport_width()to obtain the current breakpoint.
5. Input Controls
| HTML input type | vgui Support | Differences |
|---|---|---|
text/password/search/email/url/tel | Full-featured text input (cursor/selection/clipboard/IME) | ✅ |
number | Text input + numeric validation + min/max/step | ✅ |
date/datetime-local/time/month/week | Calendar popup | ✅ |
color | Preset color palette popup | 🔶 No free RGB sliders |
checkbox | 18×18 rounded box + ✓ | ✅ |
radio | roving tabindex (arrow key navigation within radiogroup) | ✅ |
range | Draggable slider | ✅ |
file | File picker | 🔶 multiple supported, accept unused |
submit/button/reset | Clickable button | ✅ Auto-binds on:submit/on:reset inside form |
hidden | Empty render | ✅ |
textarea | Multi-line text input | ✅ rows attribute |
select | Dropdown popup | ✅ options/groups/multiple/custom option content rendering |
datalist | Autocomplete suggestions | ✅ list=id association |
label | for= explicit association / wrapping association | ✅ |
form | on: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 Event | vgui Event | Support |
|---|---|---|
click | on:click | ✅ Also click() helper function for simplified signature |
keydown / keyup | on:keydown / on:keyup | ✅ |
pointerdown / pointerup / pointermove | on:pointerdown/up/move | ✅ |
dblclick | on:dblclick | ✅ |
contextmenu | on:contextmenu | ✅ |
scroll / wheel | on:scroll / on:wheel | ✅ |
resize | on:resize | ✅ |
modifiers_changed | on:modifiers_changed | ✅ |
mouse_down_out / mouse_up_out | on:mouse_down_out / on:mouse_up_out | ✅ gpui-specific |
any_mouse_down | on:any_mouse_down | ✅ gpui-specific |
input / change | on:input / on:change | ✅ Signature varies by input type |
submit / reset | on:submit / on:reset | ✅ Form context |
close | on:close | ✅ Dialog-specific |
focus/blur/mouseenter/mouseleave/load/Drag events/touch events | ❌ | gpui pointer events cover some |
7. Accessibility (ARIA)
| HTML ARIA | vgui | Support |
|---|---|---|
role attribute | role="button" etc., maps to accesskit::Role | ✅ |
aria:label | 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 | — | ✅ |
role/aria:* on input/select/textarea/label/form | Must use wrapping element | 🔶 |
HTML native a11y (alt attribute) | img alt accepted but not used for a11y | 🔶 |
| accesskit integration | gpui exposes accessibility tree via accesskit | ✅ |
8. State Management & Reactivity
| Web Framework Pattern | vgui | Support |
|---|---|---|
React useState | create_signal (SolidJS-style fine-grained) | ✅ |
React useMemo | create_memo | ✅ |
React useEffect | create_effect (synchronous execution, not async) | ✅ |
React useEffect cleanup | on_cleanup (runs on scope disposal) | ✅ |
| Dependency tracking | Automatic fine-grained tracking | ✅ |
| React Context API | Context<T> + <Provider> + use_context | ✅ |
Re-runs entire app() closure on each render | Not SolidJS compile-time fine-grained, but slot model keeps state persistent | 🔶 |
useReducer / useRef | Use signal + NodeRef as replacement | 🔶 No direct equivalent |
9. Overlays & Popups
| HTML Element | vgui | Support |
|---|---|---|
<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 API | vgui Context2D | Support |
|---|---|---|
fillRect / strokeRect | fill_rect / stroke_rect | ✅ |
clearRect | clear_rect | 🔶 No-op (immediate mode; repaints from blank each frame) |
beginPath / moveTo / lineTo / closePath | begin_path / move_to / line_to / close_path | ✅ |
quadraticCurveTo / bezierCurveTo | quadratic_curve_to / bezier_curve_to | ✅ |
arc | arc | ✅ Flattened to line segments (max(2, ceil(sweep/(π/16)))) |
fill / stroke | fill / stroke | ✅ |
fillText | fill_text | ✅ y is baseline; parses CSS font shorthand |
strokeText | stroke_text | ❌ No-op (gpui has no text outline API) |
measureText | measure_text → TextMetrics { width } | ✅ |
save / restore | save / restore | ✅ |
translate / rotate / scale | translate / rotate / scale | ✅ |
setTransform / resetTransform | set_transform / reset_transform | ✅ |
fillStyle / strokeStyle | fill_style / stroke_style (Hsla) | 🔶 Solid colors only; no gradients/patterns |
lineWidth | line_width | ✅ |
font | font (CSS shorthand, e.g. "16px sans-serif") | ✅ |
textAlign | text_align (CanvasTextAlign) | ✅ Start/Left/Center/Right/End |
globalAlpha | global_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/CSS | vgui | Support |
|---|---|---|
| No native SPA routing (relies on frameworks) | router module | ✅ |
API overview:
create_router(initial)→Router(signal-driven)match_pattern("/users/:id", path)→RouteMatchwith params:paramplaceholder +*wildcardbuild_path(pattern, params)reverse buildingrouter.navigate(cx, path)navigationrouter.render(cx, &[(pattern, callback)])declarative route matching
See Router for details.
12. Refs & Imperative Operations
| HTML DOM ref / React ref | vgui NodeRef | Support |
|---|---|---|
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 handle | Based on gpui FocusHandle + ScrollHandle; data from previous frame | 🔶 |
Direct ref on select/textarea/text input/range | Use wrapping div | 🔶 |
13. Theming System
| HTML/CSS | vgui | Support |
|---|---|---|
CSS Custom Properties (--var) | --name: value + var(--name) inside css! | ✅ |
| CSS theme switching | theme! macro + set_theme() global thread-local | ✅ |
| Automatic reactive theme changes | Must read signal inside render closure + set_theme to trigger re-render | 🔶 |
| CSS media query theme switching | Combine breakpoint + signal manually | 🔶 |
14. Cross-Platform
| HTML/CSS | vgui | Support |
|---|---|---|
| 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
| Feature | Alternative |
|---|---|
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) |
outline | border |
list-style / list-style-type | Manually draw numbers/bullets |
background-image | linear-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 events | gpui 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):
- Responsive breakpoints
sm:/md:/lg:/xl:— see Tailwind Classes - ARIA
role/aria:*attributes — see Theview!Macro, Built-in HTML Elements - SPA Router — see Router
grid-template-areas/grid-area— see CSS Property Reference
Source vs documentation discrepancies:
use_breakpoint()hook: Thecrates/vgui/src/breakpoint.rsmodule doc-comment (line 3) mentions ause_breakpoint()hook, but that function is neither implemented nor exported. The actual approach to obtain the current breakpoint is to combineBreakpoint::from_width()withreactive::get_viewport_width(). The book only documents thetw!responsive prefixes and does not cover this programmatic API.css!does not support@media: CSS Property Reference lists@mediaas an unsupportedcss!property and points totw!responsive prefixes. This description is accurate — thecss!macro itself does not support media queries; responsive capabilities are provided bytw!.
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
| Macro | Source crate | Description |
|---|---|---|
view! | vgui-view | JSX-like view markup. |
css! | vgui-css | CSS-in-Rust style declarations. |
tw! | vgui-tailwind | Tailwind utility class compilation. |
variants! | vgui | Component variant system (base + dimension styles). |
theme! | vgui | CSS variable theme builder. |
twc! | vgui | Dynamic Tailwind class composition at runtime. |
Reactivity
| Item | Description |
|---|---|
create_signal<T>(initial) -> (ReadSignal<T>, WriteSignal<T>) | Creates a reactive signal. |
create_memo<T>(f) -> ReadSignal<T> | 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<T>::get() -> T | Reads value, registers dependency. |
ReadSignal<T>::get_with(cx) -> T | Reads value without tracking. |
WriteSignal<T>::set(cx, value) | Sets value, notifies if changed. |
WriteSignal<T>::update(cx, f) -> R | Mutates value in place, notifies if changed. Returns the closure’s result. |
create_store<T>(initial) -> (Store<T>, SetStore<T>) | Creates a reactive store for aggregate state. T: Clone + 'static (no PartialEq needed). |
Store<T>::get() -> T | Reads whole state, registers dependency. |
Store<T>::with(f) -> R | Borrows state through a closure, registers dependency. |
Store<T>::select(f) -> ReadSignal<U> | Derives a fine-grained signal for a slice; only notifies when the slice changes. |
SetStore<T>::set(cx, value) | Replaces entire state, always notifies. |
SetStore<T>::update(cx, f) -> R | Mutates state in place, always notifies. |
next_auto_id() -> u64 | Stable per-render element id (used by view!). |
Mounting
| Item | Description |
|---|---|
mount(cx, render_fn) -> Entity<VguiRoot> | Creates the root entity and reactive scope. |
VguiRoot | The gpui entity owning the reactive scope. |
Router
| Item | Description |
|---|---|
create_router(initial: &str) -> Router | Creates a router backed by a path signal. |
Router::navigate(cx, path) | Updates the path signal, triggering re-render. |
Router::path() -> String | Reactive read of current path. |
Router::path_with(cx) -> String | Non-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) -> E | Render 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) -> String | Substitute :param placeholders with values. |
See Router for a full guide.
Context
| Item | Description |
|---|---|
Context<T> | Zero-sized typed marker for a context key. |
use_context(&Context<T>) -> Option<T> | Read nearest ancestor provider value. |
use_context_or(&Context<T>, || T) -> T | Read nearest provider, or fallback default. |
provide_context(&Context<T>, value) -> ProviderGuard | RAII manual provider (pops on drop). |
ProviderGuard | Guard returned by provide_context. |
See Context & Provider for a full guide.
Control flow
| Item | Description |
|---|---|
show(when, then, fallback) -> AnyElement | Conditional render with fallback. |
show_when(when, then) -> AnyElement | Conditional render (no fallback). |
for_each(items, child_fn) -> AnyElement | List rendering. |
for_each_or(items, fallback, child_fn) -> AnyElement | List rendering with fallback. |
index_list(items, child_fn) -> AnyElement | Keyed-by-position list with per-item child scopes. |
index_list_or(items, fallback, child_fn) -> AnyElement | Keyed-by-position list with fallback. |
progress(value, max) -> Div | Progress bar. |
meter(value, min, max, low, high, optimum) -> Div | Meter gauge. |
details(open, summary, content) -> AnyElement | Collapsible container. |
Overlays
| Item | Description |
|---|---|
portal(content, priority) -> AnyElement | Portal floating layer at a given priority. |
dialog(open, on_close, content) -> AnyElement | Modal dialog with portal, click-outside, escape. |
floating(position, content) -> AnyElement | Positioned floating element. |
Input widgets
| Item | Description |
|---|---|
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. |
datalist | Autocomplete suggestions for text inputs (via <datalist> element). |
Props structs
| Struct | Fields |
|---|---|
TextInputProps | kind, multiline, value, placeholder, disabled, readonly, min, max, step, on_input, on_change, style, class, id, tabindex |
TextAreaProps | value, placeholder, disabled, readonly, on_input, on_change, style, class, id, tabindex |
CheckboxProps | checked, disabled, on_change |
RadioProps | checked, disabled, on_change |
RangeProps | value, min, max, step, disabled, on_change, style, class, id, tabindex |
FileProps | multiple, on_change |
SelectProps | options: Vec<(String, String)>, groups: Vec<SelectGroup>, value, multiple, disabled, on_change |
Enums
| Enum | Variants |
|---|---|
TextKind | Text, Password, Search, Email, Url, Tel, Number, Date, DateTime, Time, Month, Week, Color |
Breakpoint | Sm, Md, Lg, Xl |
Refs
| Item | Description |
|---|---|
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
| Item | Description |
|---|---|
Css | Output of css!. Wraps a FnOnce(&mut StyleRefinement). |
TwStyle | Output of tw!/twc!. Holds base, hover, focus, active closures. |
ApplyStyle<E> | Trait for applying a style to an element. |
TwClass | Builder for composing Tailwind classes (new(), add(), add_if()). |
TwClassSource | Trait implemented for &str, String, Option<T>, TwClass. |
IntoTwStyle | Trait converting class sources into TwStyle. |
tw_dynamic(classes: &str) | Runtime Tailwind class interpreter (counterpart to tw!). |
TwAnimation | Animation definition type. |
TwTransition | Transition definition type. |
Easing | Easing function enum for animations/transitions. |
Theme | CSS variable theme store (built by theme!). |
CssValue | Enum 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<E> | Trait for spreading props onto a built-in element. |
Helpers
| Item | Description |
|---|---|
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
rustfmtformatting. Runcargo fmtbefore committing. - Run
cargo clippy --workspaceand 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).
- Proc-macro crates use hand-rolled token-tree parsing (no
- When adding a new HTML element, add it to the
emit_builtinmatch incrates/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, ortext.rs). - When adding a new Tailwind utility, add it to
emit_exactoremit_prefixedincrates/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:
- The Rust toolchain version (
rustc --version). - The OS and window system (e.g., Linux/Wayland, macOS, Windows).
- A minimal reproduction — the smallest
view!snippet that triggers the issue. - The expected behavior vs. actual behavior.
- 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.