Introduction
API status: Code examples in this book fall into two categories. Tutorial and component-reference examples use the current Bevy/Beverly API and should compile.
.header(...),.body(...), and.footer(...)are implemented content-section helpers on the supported fluent containers. Architecture and design-vision examples may still use application-owned conceptual names such asCommand::...; those are not additional built-in DSL syntax.
Beverly
What is Beverly?
Beverly is a UI framework written in Rust and built on top of the Bevy game engine.
It is designed for applications where the UI needs to be fast, local, data-heavy, and increasingly intelligent.
Beverly provides a native application runtime with:
- Small binary size
- Low resource overhead
- Fast, near-instant startup
- High-performance rendering
- Smooth 60 FPS interaction
- Local and offline execution
- Strong compile-time guarantees from Rust
- A UI architecture designed for both humans and AI agents
Beverly is not a web browser wrapped around an application. It is a UI runtime you can ship directly inside the product.
The UI for AI
AI changes what a UI needs to be.
Traditional UI frameworks were designed primarily around a human sitting in front of a screen: click a button, fill out a form, navigate a page.
AI introduces another kind of user.
An agent can inspect application state, choose actions, invoke tools, respond to events, and operate an application alongside a human.
Beverly treats AI as a first-class participant in the application, rather than bolting an AI API onto an existing UI framework.
The same application events can be produced by:
- A human clicking a button
- A keyboard shortcut
- Another application
- An API
- An AI agent
This creates a common application language shared by humans and machines.
The UI is no longer just something an AI controls. It becomes part of the interface through which AI and humans work together.
Why Rust + Bevy?
Beverly combines Rust’s systems programming model with Bevy’s high-performance rendering architecture.
Rust
Rust provides:
- Memory safety without a garbage collector
- Strong ownership and borrowing guarantees
- Compile-time type checking
- Predictable resource usage
- Safe concurrency
- Explicit application state and data flow
For an AI application, these properties have another advantage: the UI consumes fewer resources that could otherwise be used by the application and its models.
Bevy
Bevy provides a high-performance foundation for:
- Rendering
- Animation
- Input
- Windows
- Assets
- GPU acceleration
- Cross-platform applications
Beverly builds the application UI layer on top of that foundation.
The goal is simple:
Use the game engine to build the application runtime, not a game.
Small Runtime, Large Surface Area
A UI framework shouldn’t require a massive runtime just to render a button, a dashboard, or a data table.
Beverly aims to provide a surprisingly small runtime while still supporting the components required by serious applications.
That means:
- Small binaries
- Low memory overhead
- Fast startup
- Efficient rendering
- Minimal runtime dependencies
- Components for complex applications
The result is a UI runtime that can scale from a desktop application to a constrained device without bringing an entire browser stack along with it.
Local-First, Offline, Sovereign
Beverly applications run locally.
They don’t require:
- A browser
- A remote UI server
- A CDN
- Telemetry infrastructure
- A permanent internet connection
This makes Beverly well suited to applications where data sovereignty, privacy, reliability, or deployment constraints matter.
Applications can run:
- Offline
- On private networks
- In air-gapped environments
- On dedicated devices
- Inside enterprise infrastructure
- Alongside local AI models
Your UI can live where your data lives.
Human + AI Interaction
Beverly does not create a separate interface for AI.
Instead, humans and agents interact with the same application primitives.
A button might emit:
#![allow(unused)]
fn main() {
Event::Document::Save
}
A keyboard shortcut can emit the same event.
An API can emit the same event.
An AI agent can emit the same event.
The Controller routes that event to application behavior, while the Model owns the underlying state.
This gives the application a single, explicit vocabulary that both humans and machines can understand.
Rewindable AI
When application behavior is represented as explicit Events, AI actions don’t have to disappear into an opaque execution log.
The application can record the sequence of events, the resulting state transitions, and the relevant context around an agent’s actions.
That makes AI behavior observable, rewindable, and replayable.
A human can inspect what the agent did:
User::Open(8472)
↓
Document::Read(8472)
↓
Document::Summarize(8472)
↓
Document::Create("Quarterly Report")
↓
Document::Save(9124)
They can then replay the sequence to understand what happened, inspect individual decisions, or rewind the application to an earlier point.
This creates a fundamentally different relationship between humans and AI:
The AI doesn’t just act. Its actions become part of the application’s observable history.
Instead of asking “What did the AI do?”, the application can show you.
Instead of asking “Why is the application in this state?”, you can trace the events that produced it.
And instead of treating AI behavior as something that happened somewhere inside an opaque model, Beverly makes the application-level consequences of that reasoning inspectable and reproducible.
This is the foundation for AI visibility: humans remain able to observe, understand, rewind, and replay what their AI systems are doing.
Design Philosophy
Beverly is built around a few core principles.
Explicitness over magic
The implementation can be sophisticated. The application API should remain understandable.
Magic in implementation. Explicitness at the API.
Ordinary Rust whenever possible
If something can be solved cleanly with ordinary Rust, Beverly should not introduce another abstraction for it.
Model, View, Controller, Events
Beverly uses a lightweight MVC architecture:
- Model — owns application state and behavior
- View — represents and renders the UI
- Controller — connects application events to behavior
- Events — define the application’s vocabulary and contracts
Events provide the connection between the UI, application logic, external systems, and AI agents.
Composition over complexity
Beverly components should be small, composable primitives.
#![allow(unused)]
fn main() {
card()
.padding(16)
.radius(12)
.children([
text("Hello"),
button().text("Save"),
])
}
The goal is to make applications easy for both humans and AI systems to understand and construct.
When to Use Beverly
Beverly is particularly well suited to applications where the UI itself is part of the product’s infrastructure.
Consider Beverly when you need:
Local applications
Applications that should run directly on the user’s machine or device.
Resource-constrained environments
Applications where CPU, memory, storage, startup time, or bandwidth matter.
Data-heavy interfaces
Applications dealing with:
- Large datasets
- Streaming data
- Data visualization
- High-frequency updates
- Tables and grids
- Telemetry
- Scientific or operational data
AI-native applications
Applications where AI agents need to interact with the same state, events, and tools as human users.
Sovereign applications
Applications that need to operate:
- Offline
- Inside private infrastructure
- In air-gapped environments
- With sensitive local data
- Alongside local AI models
Dedicated devices
Applications where a browser runtime is unnecessary overhead.
When Not to Use Beverly
Beverly is not intended to replace the web.
A traditional web application may be the better choice when:
- The application is primarily web-based
- Browser deployment is the primary requirement
- HTML and CSS are already a strong fit
- You need the existing web ecosystem
- You want users to access the application without installing anything
- Your application benefits heavily from existing JavaScript libraries and browser APIs
If a conventional web stack solves the problem well, use it.
Beverly exists for the cases where you want something different:
A native, local, high-performance UI runtime designed for data, devices, and AI.
2. Quick Start
Beverly is designed to get you from an empty Rust project to a complete interactive application quickly.
This tutorial builds a tiny application with:
- A reusable Button component
- A Rust Model that owns application data
- A typed Bevy Message
- A Controller plugin connecting the Message to application behavior
- A View that reflects the Model
- Built-in accessibility
- A structure that is straightforward to test
The complete flow is:
View
↓
Message
↓
Controller
↓
Model
↓
Application Data
↓
View
2.1 Installation
Create a new Rust project:
cargo new hello_beverly
cd hello_beverly
Add Bevy and Beverly:
cargo add bevy
cargo add beverly
Beverly is a Rust crate, so you work with it the same way you work with other Rust dependencies.
2.2 Hello, Beverly
Start with a simple application:
use bevy::prelude::*;
use beverly::prelude::*;
fn main() {
App::new().ui(my_ui()).run();
}
fn my_ui() -> Ui {
ui()
.theme(light_theme())
.center()
.children([text("Hello, Beverly!")])
}
That’s a Beverly application.
There is no browser, JavaScript runtime, HTML document, or separate frontend server.
Beverly runs as part of your native Rust application.
App::ui(...) is the primary entry point: it installs BeverlyPlugin, applies
the theme, and spawns your Ui tree as the application’s root, so you never
need to think in terms of entities, components, or startup systems. ui()
returns a Ui, the root composition itself - configure layout, theme, and
children before returning it:
#![allow(unused)]
fn main() {
fn my_ui() -> Ui {
ui()
.width(percent(100))
.height(percent(100))
.theme(dark_theme())
.children([text("Workspace")])
}
}
2.3 Your First Component
Beverly provides reusable UI primitives.
Create a button:
#![allow(unused)]
fn main() {
button().text("Click Me")
}
The same Button component can be reused throughout your application:
#![allow(unused)]
fn main() {
button().text("Save")
button().text("Cancel")
button().text("Delete")
button().text("Create User")
}
Build a larger interface by composing components:
#![allow(unused)]
fn main() {
App::new()
.ui(ui().children([button().text("Get Started")]))
.run();
}
For button content, use .children([ButtonChild::icon(...), ButtonChild::text(...)]).
Other Beverly components use the shared ui().children([...]) composition API.
Build a primitive once. Compose and reuse it everywhere.
2.4 Accessibility Is Part of the Component
Accessibility is not an afterthought in Beverly.
It is part of the component contract.
For example, a button gets its accessible name from its text content:
#![allow(unused)]
fn main() {
button().text("Click Me")
}
Beverly treats accessibility as a first-class runtime concern rather than something added during a final QA pass. The button plugin projects the text content into its semantic node; custom content should include a text child so assistive technology has a meaningful name.
The goal is simple:
Accessible by construction, not accessible by cleanup.
This applies to Beverly’s complex UI primitives as well — forms, navigation, dialogs, trees, data interfaces, keyboard interaction, and other components where accessibility cannot be reduced to a single visual label.
Accessibility should be something the framework helps you get right automatically, while still making the important semantics explicit.
2.5 Your First Model
The Model is ordinary Rust.
It owns the application’s underlying data.
You don’t need a separate state-management system just to keep your UI synchronized.
For our application:
#![allow(unused)]
fn main() {
struct AppModel {
clicks: u32,
}
impl AppModel {
fn clicks(&self) -> u32 {
self.clicks
}
fn set_clicks(&mut self, clicks: u32) {
self.clicks = clicks;
}
fn increment_clicks(&mut self) {
self.set_clicks(self.clicks() + 1);
}
}
}
The important distinction is that the Model owns the data and exposes the operations that make sense for that data.
Getters read it.
Setters and Model methods change it.
The Model can also enforce application rules:
#![allow(unused)]
fn main() {
fn set_clicks(&mut self, clicks: u32) {
self.clicks = clicks.min(100);
}
}
Now the invariant lives with the data rather than being scattered throughout the UI.
2.6 Your First Message
Messages define the vocabulary of your application.
Declare one:
#![allow(unused)]
fn main() {
event! {
App::Clicked
}
}
The macro generates a Bevy Message type. Register it explicitly:
#![allow(unused)]
fn main() {
app.add_message::<App::Clicked>();
}
2.7 Connect the Button
Attach an explicit callback to the Button:
#![allow(unused)]
fn main() {
fn click_button(commands: &mut Commands, _button: Entity) {
commands.write_message(App::Clicked);
}
button()
.text("Click Me")
.on("click", click_button)
}
The Button does not emit application messages automatically. The callback is the explicit attachment point; other inputs may write the same message through their own application systems.
2.8 Your First Controller
The Controller connects Messages to behavior:
#![allow(unused)]
fn main() {
controller! {
AppController {
App::Clicked => handle_click,
}
}
fn handle_click(_message: &App::Clicked) {
// Call the model's domain method here.
}
}
The Controller does not define the Message.
The Message was already defined by event!.
The Controller simply says:
When this Message occurs, run this handler.
This keeps the application’s vocabulary separate from its wiring.
The basic relationship is:
Message = Contract
Controller = Connection
Model = Owner
View = Projection
2.9 The View Reflects the Model
The View reads the current state of the Model:
#![allow(unused)]
fn main() {
text(format!("Clicks: {}", model.clicks()))
}
The View does not own the application’s underlying data.
It projects the current Model state into the UI.
The relationship is straightforward:
Model changes
↓
View reads current state
↓
Bevy updates/rendering
↓
UI reflects the change
Beverly runs as part of Bevy’s rendering/update loop. At 60 FPS, a new frame is available roughly every 16.7 milliseconds.
That means a Model change can be reflected essentially immediately in the UI.
Change the Model. The UI reflects the Model.
2.10 Why Beverly Is Easy to Test
Beverly’s architecture makes testing straightforward because each major part has a clear responsibility.
Test the Model as Rust
The Model contains your application logic and data.
You can test it without starting a window, rendering a frame, or interacting with the UI:
#![allow(unused)]
fn main() {
#[test]
fn increments_clicks() {
let mut model = AppModel { clicks: 0 };
model.increment_clicks();
assert_eq!(model.clicks(), 1);
}
}
This is just a Rust test.
Test the View as Bevy
The View is built from Bevy components.
That means View behavior can be tested using the same Bevy-oriented testing techniques used elsewhere in the application.
You don’t need a second UI architecture or a browser automation layer simply to exercise your component tree.
Test Events as contracts
Events are typed.
You can test that an interaction produces the expected Event and that the Event contains the expected payload.
Test Controllers as wiring
Controllers are explicit mappings:
#![allow(unused)]
fn main() {
App::Clicked => Model::App::increment_clicks
}
That makes the application’s behavior easy to reason about and easy to test.
The result is a useful property:
The application is testable because its architecture is explicit.
There isn’t one enormous UI layer that has to be tested as a black box.
The Model, View, Events, and Controllers each have a clear boundary.
2.11 Project Structure
A small application can start with a few files.
As it grows, Beverly’s architecture maps naturally onto your Rust project:
hello_beverly/
├── Cargo.toml
├── src/
│ ├── main.rs
│ ├── model/
│ │ └── app.rs
│ ├── view/
│ │ └── app.rs
│ ├── events/
│ │ └── app.rs
│ └── controller/
│ └── app.rs
└── assets/
A larger application might look like:
src/
├── main.rs
├── model/
│ ├── user.rs
│ ├── document.rs
│ └── application.rs
├── view/
│ ├── dashboard.rs
│ ├── users.rs
│ └── documents.rs
├── events/
│ ├── user.rs
│ └── document.rs
└── controller/
└── application.rs
This isn’t a requirement.
It is simply a natural way to organize a Beverly application.
2.12 Running Your Application
Run your application normally:
cargo run
Check it without running:
cargo check
Build an optimized release:
cargo build --release
2.13 Beverly Commands
Beverly can also provide application-oriented Cargo commands.
For example:
cargo beverly build
Build a development application.
For distribution:
cargo beverly publish
The goal of publish is to feel like a natural extension of cargo build, while producing the artifacts you actually distribute.
Target-specific publishing can then be extended modularly:
cargo beverly publish --target macos
cargo beverly publish --target windows
cargo beverly publish --target linux
The initial implementation can target macOS while leaving the command structure open for additional platforms.
2.14 What You Just Built
You started with an empty Rust project and ended with the basic architecture of a Beverly application:
┌───────────┐
│ View │
└─────┬─────┘
│
Event
│
┌─────▼─────┐
│ Controller│
└─────┬─────┘
│
Action
│
┌─────▼─────┐
│ Model │
└─────┬─────┘
│
Application
Data
│
▼
View
The Button is reusable.
The Model is ordinary Rust.
The View is built from Bevy.
The Event defines the application’s vocabulary.
The Controller connects Events to behavior.
Accessibility is part of the component contract.
And because each part has a clear responsibility, each part can be tested independently.
This is the foundation Beverly builds on:
The Event is the contract. The Controller is the connection. The Model is the owner. The View is the projection.
Application Structure
Beverly applications are organized around a simple idea:
Every concern has a place, and every place has a clear responsibility.
The goal is not architectural complexity. It is separation without ceremony.
A developer should be able to open an unfamiliar Beverly application and quickly answer:
- Where does the data live?
- Where can that data change?
- What does the interface look like?
- What actions can the application perform?
- What happens when an event occurs?
- Where should I make this change?
The architecture makes those answers predictable.
Application
│
├── Model
│ └── Owns application data and domain rules
│
├── View
│ └── Presents data and collects interaction
│
├── Events
│ └── Define what happened and what can happen
│
└── Controller
└── Connects events to application actions
This is the application’s vocabulary.
One Responsibility, One Place
Beverly deliberately separates data, presentation, behavior, and communication.
The Model owns the data.
The View presents the data.
Events describe what happened or what should happen.
The Controller connects those events to actions.
That means a change should usually have an obvious destination.
Changing how data works? Look in the Model.
Changing how something looks? Look in the View.
Adding an application action? Define a Command.
Responding to something that happened? Listen for its Fact.
Changing what happens in response? Look in the Controller.
You don’t need to search through a giant application file to discover where behavior lives.
A Structure LLMs Can Understand
Modern applications are increasingly written with AI.
That changes what good architecture means.
A codebase may be edited by one developer, several developers, or multiple LLMs working simultaneously. The architecture should make it difficult for one part of the system to accidentally interfere with another.
Beverly uses explicit boundaries to make this possible.
For example, an LLM working on the View should primarily be concerned with:
View
↓
Components
↓
Events
An LLM working on the Model should primarily be concerned with:
Model
↓
Application Data
↓
Domain Rules
An LLM working on Controllers should primarily be concerned with:
Events
↓
Actions
↓
Model / Services
The boundaries give both humans and machines a smaller area to reason about.
The smaller the surface of a change, the easier the change is to understand, review, and verify.
Multiple LLMs, One Application
Beverly is designed for a development environment where multiple agents may work on the same application.
One LLM might be building a new component.
Another might be implementing a database integration.
Another might be adding an agent action.
Another might be improving accessibility.
They should not all need to understand the entire application.
Because concerns are separated, their work can remain localized.
Application
│
┌───────────┼───────────┐
│ │ │
View Model Controller
│ │ │
Components Data Behavior
│ │ │
└─────── Events ────────┘
The important property is that one agent does not need to modify another agent’s area of responsibility to accomplish its own task.
A View agent should not need to rewrite Model state.
A Model agent should not need to restructure the UI.
A Controller agent should not need to duplicate application logic inside event handlers.
The architecture creates boundaries between pieces of work.
This reduces accidental overwrites, duplicated logic, and changes made in the wrong place.
Explicit State Ownership
State ownership is especially important when multiple agents are generating code.
Beverly does not encourage every component to invent its own source of truth.
The Model owns application data.
The Controller determines how application actions are connected.
The View reflects the resulting state.
Model
owns state
│
▼
View
reflects state
│
▼
Event
expresses intent
│
▼
Controller
connects action
│
▼
Model
This creates a predictable direction of travel.
The View does not quietly become another Model.
A component does not secretly become the owner of application state.
A Controller does not become a second database.
There is one obvious place to look for the authoritative state.
One source of truth. Clear ownership. Predictable flow.
That matters even more when code is being generated or modified by machines.
Easy to Find. Easy to Change.
A good architecture should reduce the amount of code you need to read before making a change.
Suppose you want to change the way users are displayed.
You should primarily work in the View.
users/
├── model.rs
├── view.rs
├── events.rs
└── controller.rs
Suppose you want to change how users are stored.
Look at model.rs.
Suppose you want to add:
User::Delete
Look at the Events and Controller.
Suppose you want deleting a user to trigger an audit record.
Add the appropriate event/action connection rather than embedding auditing logic inside an unrelated component.
The structure itself becomes a map of the application.
Local Reasoning
Beverly favors local reasoning.
You should be able to understand a component without understanding the entire application.
You should be able to understand a Model without loading the renderer into your head.
You should be able to understand a Controller without understanding every screen.
You should be able to understand an Event without knowing which View generated it.
This makes code review dramatically simpler.
It also makes AI-generated code easier to evaluate.
An LLM can generate a change inside a constrained boundary.
A human can inspect that boundary and determine whether the change makes sense.
The architecture doesn’t eliminate mistakes.
It makes mistakes easier to locate.
A Predictable Project
A Beverly project can remain simple even as the application grows.
src/
├── main.rs
│
├── model/
│ ├── user.rs
│ ├── document.rs
│ └── settings.rs
│
├── view/
│ ├── users.rs
│ ├── documents.rs
│ └── settings.rs
│
├── events/
│ └── mod.rs
│
└── controller/
└── mod.rs
The exact directory structure is not the important part.
The important part is that the conceptual structure remains stable.
Data → Model
Interface → View
Vocabulary → Events
Wiring → Controller
A small application and a large application can use the same mental model.
Architecture as a Contract
Beverly’s architecture is intentionally boring in the best possible way.
There are no hidden state machines that developers have to learn.
No giant application controller.
No requirement that every piece of data pass through a proprietary state-management abstraction.
No reason for every component to know about every other component.
Instead, Beverly establishes a small set of contracts:
Model
owns data
View
presents data
Event
describes intent or reality
Controller
connects intent to behavior
Once those contracts are understood, the rest of the application becomes composition.
This is particularly important for AI-native development.
An LLM doesn’t need to understand an entire codebase to make a useful change.
It needs to understand the relevant contract, make the change inside that boundary, and follow the existing vocabulary.
The Application Becomes a Map
The ultimate goal is predictability.
When something doesn’t work, you know where to look.
When something needs to change, you know where to make the change.
When an LLM generates code, you know where that code belongs.
When multiple LLMs work simultaneously, their responsibilities can remain separated.
When a new developer joins the project, the architecture itself explains how the application works.
The codebase should tell you where to look.
That’s the point of Beverly’s application structure.
Not abstraction for its own sake.
Not architecture as ceremony.
Clear concerns. Clear ownership. Clear boundaries. Clear code.
The Beverly Rule
If you are unsure where something belongs, ask what the code is responsible for:
Does it own data? → Model
Does it present data? → View
Does it describe something happening or something you want to happen? → Event
Does it connect an event to behavior? → Controller
If the answer is still unclear, the code may be doing too much.
Simple enough for a human to navigate. Structured enough for multiple machines to work on simultaneously.
3.2 Model
The Model owns the application’s data.
In Beverly, a Model is a plain Rust data struct.
There is no separate state-management framework or hidden ECS resource. The application owns how and where the value is stored and how domain methods are added.
#![allow(unused)]
fn main() {
struct UserModel {
name: String,
email: String,
}
}
The struct is ordinary Rust. Add domain methods and validation in a normal
impl UserModel block:
#![allow(unused)]
fn main() {
impl UserModel {
fn name(&self) -> &str {
&self.name
}
fn email(&self) -> &str {
&self.email
}
fn set_name(&mut self, name: String) {
self.name = name;
}
fn set_email(&mut self, email: String) {
self.email = email;
}
}
}
That’s a Beverly Model.
#![allow(unused)]
fn main() {
struct OtherModel {
value: u32,
}
}
Models do not require a Beverly derive, macro, resource registration, or hidden runtime behavior.
Familiar by Design
Beverly deliberately uses a familiar getter/setter pattern.
Getters read the data.
Setters change the data.
Domain methods perform operations.
#![allow(unused)]
fn main() {
impl UserModel {
fn rename(&mut self, name: String) {
self.set_name(name);
}
}
}
This is intentionally boring.
That’s a feature.
A developer can look at a Model and immediately understand what it owns and how it can change.
An LLM can recognize the pattern immediately and reliably generate new Models from it.
If an application already has a UserModel, an AI can predict what a DocumentModel, AccountModel, or ProjectModel should look like without having to learn a proprietary state-management system.
The Model should be obvious.
Rust Safety Without Rust Complexity
Because Models are ordinary Rust, they inherit Rust’s safety guarantees.
Data has a clear owner.
Access to that data is controlled through the Model’s interface.
Rust’s compiler handles the underlying memory-safety guarantees without requiring the developer to build their application around complicated ownership patterns.
Beverly does not ask developers to fill their application code with Arc<Mutex<T>>, elaborate borrowing patterns, or other advanced Rust techniques simply to manage application state.
The framework can handle complexity internally.
The application code stays simple.
Model
│
├── owns data
│
├── getters → read
│
├── setters → change
│
└── methods → behavior
This gives Beverly an important combination:
Familiar application architecture + Rust’s safety guarantees.
Models Own Their Access Control
A Model should also own the rules governing access to its data.
The UI should not be responsible for deciding whether an operation is permitted.
An AI agent should not be responsible for deciding whether an operation is permitted.
An API should not have to duplicate the same business rules.
The Model is the authoritative owner of the data and its domain rules.
For example:
#![allow(unused)]
fn main() {
impl UserModel {
fn set_email(&mut self, email: String) -> Result<(), UserError> {
if !is_valid_email(&email) {
return Err(UserError::InvalidEmail);
}
self.email = email;
Ok(())
}
}
}
The rule travels with the data.
Every path that ultimately changes the Model goes through the same domain logic.
This becomes particularly important in AI-native applications.
An AI agent may request:
User::Delete
But the agent does not get to bypass the application’s rules.
The application decides whether that operation is authorized.
The Controller can enforce application-level policy, while the Model enforces the integrity and domain rules of the data itself.
The Model owns the data. It should also own the rules that keep that data valid.
Models Can Talk to Anything
A Model isn’t limited to in-memory data.
It can connect to whatever the application needs.
For example:
Model
│
├── SQLite
├── PostgreSQL
├── APIs
├── Parquet
├── Files
├── Streams
├── Data Lakes
└── AI-generated data
A DocumentModel might load documents from a database.
A TelemetryModel might consume a high-throughput stream.
A DataModel might query Parquet files.
A UserModel might communicate with an API.
The View doesn’t need to know where the data came from.
It simply reads the Model.
Database / Parquet / API / Stream
↓
Model
↓
View
This keeps the UI independent from the underlying data source.
It also makes Beverly suitable for applications that work with large, local, streaming, or heterogeneous datasets.
Models Are Easy to Test
Because a Model is ordinary Rust, testing one doesn’t require launching the application.
You can write a standard Rust unit test:
#![allow(unused)]
fn main() {
#[test]
fn user_email_can_be_changed() {
let mut user = UserModel {
name: "Alice".into(),
email: "[email protected]".into(),
};
user.set_email("[email protected]".into());
assert_eq!(user.email(), "[email protected]");
}
}
Domain rules can be tested the same way:
#![allow(unused)]
fn main() {
#[test]
fn invalid_email_is_rejected() {
let mut user = UserModel {
name: "Alice".into(),
email: "[email protected]".into(),
};
assert!(user.set_email("not-an-email".into()).is_err());
}
}
No window.
No renderer.
No UI automation.
No browser.
Just Rust.
This is one of Beverly’s biggest architectural advantages:
If your application logic is Rust, test your application logic with Rust.
The same simplicity extends to AI-generated code.
An LLM can generate a Model.
A developer can read it.
A developer can modify it.
A standard Rust test can verify it.
And Rust’s compiler can enforce its safety.
The Model’s Role
The Model has a deliberately narrow responsibility:
Own the data. Define how that data can change. Enforce its domain rules. Connect to the underlying data sources.
It does not render the UI.
It does not decide how a Button looks.
It does not need to know whether a human or AI initiated an operation.
Those responsibilities belong elsewhere.
Event
↓
Controller
↓
Model
↙ ↓ ↘
Rules Data Sources
↓
State
↓
View
The result is a Model that is familiar to programmers, predictable for AI code generation, straightforward to test, and backed by Rust’s safety guarantees.
The Model is just Rust — and that’s exactly the point.
3.3 View
The View is the interface of the application.
It turns application state into an interactive, visual experience using Beverly’s component system.
Beverly provides a library of reusable primitives — Buttons, Cards, Inputs, Modals, Dropdowns, Tables, Navigation, Alerts, Badges, and more — that can be composed into larger interfaces.
The goal is simple:
Build the primitive once. Compose it everywhere.
Built-In Components
Beverly comes with a growing set of UI primitives designed for real applications.
Layout
├── Container
├── Row
├── Column
└── Stack
Input
├── Button
├── Input
├── Checkbox
├── Toggle
└── Dropdown
Content
├── Card
├── Text
├── Image
├── Icon
└── Table
Feedback
├── Alert
├── Toast
├── Modal
└── Badge
Navigation
├── Navbar
├── Tabs
├── Menu
└── Sidebar
These are not one-off widgets.
They are reusable building blocks intended to form the foundation of an application’s design system.
A Button can be used once or a thousand times.
#![allow(unused)]
fn main() {
button().text("Save")
button().text("Cancel")
button().text("Delete")
button().text("Create User")
}
The same primitives can be composed into completely different interfaces without creating a new implementation for every screen.
Composition
Beverly views are built through composition.
#![allow(unused)]
fn main() {
button()
.text("Get Started")
.on("click", open_document)
}
In the current Bevy implementation, open_document is an application function
with signature fn(&mut Commands, Entity). Use app().children([...]) and the
shared fluent composition API to build larger views.
Components contain other components.
Components can be nested.
Components can be reused.
This allows small primitives to become increasingly sophisticated interfaces without requiring increasingly complicated abstractions.
Primitive
↓
Component
↓
Composite Component
↓
Application View
↓
Complete Application
The same principle applies to your own components.
You can build a reusable UserCard from a Card, Text, Image, and Button, then use UserCard throughout the application.
Complex interfaces are compositions of simple things.
Familiar Syntax
Beverly deliberately uses a fluent API for component construction.
#![allow(unused)]
fn main() {
card()
.padding(16)
.radius(12)
.children([
text("Hello"),
button().text("Save"),
])
}
Traits and methods can be chained together to progressively describe a component.
If you’ve ever written jQuery, the pattern should feel familiar:
select something
↓
configure it
↓
configure it again
↓
attach behavior
↓
compose
Beverly brings that familiar style to a strongly typed Rust UI.
The difference is that the compiler understands the API.
You get fluent composition without giving up Rust’s type system.
Familiar syntax. Strong types. Composable components.
Designed for Design Systems
Beverly is designed to make building and maintaining a design system a first-class use case.
Instead of every application inventing its own Button, Modal, Input, Dropdown, or notification system, teams can establish a common vocabulary of primitives and compose applications from them.
A design system might define:
#![allow(unused)]
fn main() {
button().text("Save")
button().text("Cancel")
button().text("Delete")
}
Those components can themselves be built on Beverly primitives.
Beverly Primitives
↓
Design System
↓
Application Components
↓
Application
This creates consistency across an application without forcing every screen to share the same implementation.
The primitives provide the foundation.
The design system provides the language.
The application composes that language into an experience.
Accessibility by Construction
Accessibility is part of Beverly’s component architecture.
It is not something developers are expected to remember at the end of a project.
Beverly primitives carry accessibility requirements as part of their contracts.
For example, an interactive Button has an accessibility/ARIA requirement:
#![allow(unused)]
fn main() {
button().text("Save")
}
The button plugin derives the accessible name from .text(...) or the first text child supplied to .children(...). Application code should still test dynamic labels and semantic relationships with assistive technology.
Instead of:
Build
↓
QA
↓
Accessibility audit
↓
Discover missing semantics
↓
Fix
Beverly aims for:
Write UI
↓
Compiler checks requirements
↓
Accessible component
Accessibility becomes part of the programming model.
This is particularly important for complex enterprise interfaces where accessibility problems can become expensive compliance and legal issues.
Beverly’s philosophy is:
Don’t remember to add accessibility. Build accessibility into the primitive.
The goal is not merely to provide accessibility documentation.
The goal is to make entire categories of accessibility mistakes impossible to ship when they can be detected statically.
Modular by Design
Beverly’s component system is modular.
You don’t need to build your application as one enormous UI tree.
Small components can be assembled into larger components, which can then become reusable application primitives themselves.
#![allow(unused)]
fn main() {
fn user_card(user: &UserModel) -> impl Component {
card()
.padding(16)
.children([
text(user.name()),
button().text("View Profile"),
])
}
}
That component can then be reused anywhere:
#![allow(unused)]
fn main() {
column()
.children([
user_card(&alice),
user_card(&bob),
user_card(&charlie),
])
}
The architecture encourages developers to create a vocabulary that matches their application.
A financial application can build AccountCard, TransactionTable, and PortfolioChart.
A healthcare application can build PatientCard, AppointmentList, and RecordViewer.
An AI application can build ToolCallCard, AgentStatus, ApprovalDialog, and EventInspector.
All of them are compositions of the same underlying primitives.
Reusability Is the Point
A UI framework becomes powerful when its components stop being screens and start becoming assets.
A Button isn’t a screen.
A Card isn’t a screen.
A Dropdown isn’t a screen.
They are reusable pieces that can appear throughout an application and across applications.
That means improvements to the primitive can propagate everywhere it is used.
Accessibility improvements.
Visual improvements.
Performance improvements.
Interaction improvements.
A component becomes a maintained piece of infrastructure rather than duplicated application code.
The View’s Role
The View has a deliberately focused responsibility:
Present application state, provide interaction, and compose the interface from reusable components.
It does not own the application’s underlying data.
It does not contain the application’s business rules.
It does not need to know whether an interaction came from a human or an AI agent.
It presents the current Model and emits Events when users interact with it.
Model
│
State
↓
View
│
┌───────┴────────┐
↓ ↓
Components Events
│ │
Compose + Controller
Render │
↓
Model
The result is a View system that is familiar to developers, predictable for AI-generated code, modular enough for serious design systems, and accessible by construction.
The View is where simple primitives become an application.
3.4 Events
Events are the vocabulary of a Beverly application.
They describe what can happen and what has happened.
event! declares ordinary Bevy Message structs in namespaces. Registration
and handling remain explicit application responsibilities:
#![allow(unused)]
fn main() {
use bevy::prelude::*;
use beverly::prelude::*;
event! {
User::Create {
name: String
}
User::Delete {
id: u64
}
}
fn setup(mut app: App) {
app.add_message::<User::Create>()
.add_message::<User::Delete>();
}
}
Write and read them with Bevy’s normal message APIs:
#![allow(unused)]
fn main() {
fn create_user(mut messages: MessageWriter<User::Create>) {
messages.write(User::Create {
name: "Alice".to_string(),
});
}
fn handle_users(mut messages: MessageReader<User::Create>) {
for message in messages.read() {
info!(name = %message.name, "creating user");
}
}
}
The macro declares types only. It does not register messages, route commands, or emit events automatically; those behaviors stay explicit in the systems and plugins that own the application contract.
Beverly uses two kinds of Events:
- Commands — things you want to happen.
- Facts — things that happened.
Command
│
│ "I want this to happen."
▼
Application
│
│ "This happened."
▼
Fact
This distinction gives applications a simple, predictable language for describing behavior.
Commands
A Command expresses an intention.
It tells the application what you want it to do.
#![allow(unused)]
fn main() {
event! {
User::Create {
name: String
}
User::Delete {
id: UserId
}
}
}
These Events describe actions the application understands:
User::Create
User::Delete
A human can trigger them.
An API can trigger them.
Another application can trigger them.
An AI agent can trigger them.
The Event is the common interface.
There is no separate API for AI agents to learn.
Facts
A Fact describes something that has already happened.
#![allow(unused)]
fn main() {
event! {
User::Created {
id: UserId
}
User::Deleted {
id: UserId
}
}
}
The distinction is simple:
CreateUser
= Command
= "Create this user."
UserCreated
= Fact
= "This user was created."
Commands express intent.
Facts express reality.
This makes it possible for different parts of the application to react to the same facts without needing to know which component originally caused them.
Events Are Strongly Typed
Events are defined as typed Rust contracts.
#![allow(unused)]
fn main() {
event! {
User::Create {
name: String
}
}
}
The compiler knows what User::Create requires.
An invalid payload is not a runtime mystery.
It is a type error.
Rust catches malformed Events at compile time.
This gives Events a property that is particularly valuable for AI-generated code:
The compiler can tell the AI when it generated something that doesn’t match the application’s contract.
The same type system that protects ordinary Rust code protects the application’s event vocabulary.
Events as Self-Documenting Code
An Event definition tells you what the application can do without requiring you to read the implementation first.
#![allow(unused)]
fn main() {
event! {
Document::Summarize {
id: DocumentId
}
Document::Delete {
id: DocumentId
}
Document::Publish {
id: DocumentId
}
}
}
You can glance at this and immediately understand three capabilities of the application.
That is intentional.
Beverly aims to reduce boilerplate by making the code itself the documentation.
Instead of maintaining separate lists of:
- API endpoints
- AI tools
- event names
- documentation
- schemas
- permissions
- telemetry definitions
the Event becomes the authoritative definition from which these things can be derived.
The best documentation is the code that cannot lie.
Events Are an Interface for AI
This becomes especially powerful when an AI agent participates in the application.
The agent doesn’t need to understand the entire application implementation.
It needs to understand the application’s vocabulary.
For example:
User::Create
name: String
User::Delete
id: UserId
Document::Summarize
id: DocumentId
The agent can discover these Commands and determine what actions are available.
To perform an action, it simply issues the corresponding Command.
Agent
│
│ User::Create { name: "Alice" }
▼
Controller
│
▼
Model
The agent doesn’t need a special Agent::CreateUser interface.
It uses the same application vocabulary as everything else.
Agents can also listen for Facts:
UserCreated
DocumentPublished
PaymentCompleted
An agent can respond when the Fact it cares about occurs.
UserCreated
↓
Agent observes Fact
↓
Agent decides what to do
↓
Agent issues Command
This produces a very simple agent loop:
Listen for Facts. Decide. Issue Commands.
Events and Observability
Beverly Events are also observable by design.
Under the hood, Beverly’s Event system integrates with OpenTelemetry so that Events can carry standardized telemetry information as part of their normal lifecycle.
That means application activity can be observed without developers having to build a separate logging architecture around every action.
Conceptually:
Event
↓
Action
↓
State Change
↓
Telemetry
↓
Event History
The important idea is that the application already knows what happened.
The Event is the natural unit of application activity.
Logging doesn’t need to be a separate thing that developers remember to add everywhere.
Playback and Rewind
Because Events form an ordered history of application activity, playback becomes remarkably straightforward.
Instead of trying to reconstruct what happened from scattered logs, you can step through the application’s Event history:
Event 001
↓
Event 002
↓
Event 003
↓
Event 004
↓
Current State
Replaying the application means replaying its Events.
Rewinding means moving back through that history and reconstructing the corresponding state.
This creates an important capability for AI-native applications.
When an agent changes something, its actions become part of the same observable application history as human actions.
A developer can inspect:
Fact: DocumentCreated
Command: Document::Summarize
Fact: DocumentSummarized
Command: Document::Publish
Fact: DocumentPublished
The AI’s behavior is no longer hidden behind a collection of opaque tool calls.
It becomes part of the application’s history.
If the application records what happened, the application can show you what happened.
How Events Are Stored
The application state you see at any moment is not the source of truth.
The Event history is.
Event Log (append-only)
│
├── 001 Fact: DocumentCreated { id: 1 }
├── 002 Command: Document::Summarize { id: 1 }
├── 003 Fact: DocumentSummarized { id: 1 }
├── 004 Command: Document::Publish { id: 1 }
└── 005 Fact: DocumentPublished { id: 1 }
Every Event is written to this log in the order it occurred, and it is never edited or removed once written.
Each entry carries the information required to reproduce it later:
Sequence → position in history
Timestamp → when it happened
Event → the typed Command or Fact
Payload → its strongly typed data
The Model’s current state is simply what you get from folding the log from the beginning:
State(0) + Event 001 + Event 002 + ... + Event N = Current State
Because the log is append-only, this is the same guarantee databases call an event-sourced or write-ahead log: history is immutable, and current state is a derived, disposable projection of it.
The log is the truth. The current state is just a cache of it.
Saving Event History
Saving an application’s history means saving its Event log, not its in-memory Models.
Event
↓
Serialize (typed payload → bytes)
↓
Append to log (disk, database, or stream)
↓
Acknowledge
Because Events are strongly typed Rust values, they serialize the same way any other typed Rust data does. There is no bespoke save format to invent per feature.
The log can live wherever it makes sense for the application:
Event Log
│
├── a local file
├── SQLite / Postgres
├── an append-only object store
└── a distributed log (e.g. Kafka-style stream)
Saving does not require freezing the whole application. Only the new Events since the last save need to be appended, which is what keeps this cheap even for long-running applications.
Persistence is just “keep appending.” Nothing about the Model needs a separate save/load path.
Replaying
Replaying means reading the Event log from the start and re-applying every Event, in order, to rebuild state.
Empty State
│
├── apply Event 001
├── apply Event 002
├── apply Event 003
├── ...
└── apply Event N
↓
Reconstructed State (identical to the original)
Because Facts describe what already happened, replaying them is deterministic: applying the same Events in the same order always produces the same resulting state.
This is useful in several concrete situations:
Restarting the application
→ replay the log to rebuild state from nothing
Debugging a production issue
→ replay a copied log locally to reproduce it exactly
Testing
→ replay a recorded scenario as a regression test
Migrating a Model
→ replay old Events through new logic to backfill new state
Nothing about “restoring the application” is special-cased. It’s the same replay used everywhere else.
Rewinding
Rewinding moves state backward by replaying only a prefix of the log, rather than the whole thing.
Event 001 → Event 002 → Event 003 → Event 004 → Event 005
▲
rewind target: after Event 003
To rewind to a point in time, the application discards nothing. It simply re-derives state by folding the log up to that Event and stopping:
State(0) + Event 001 + Event 002 + Event 003 = State at that point in time
The Events after the rewind point are not deleted. They remain in the log. Rewinding is a read operation over history, not a mutation of it.
Rewind ≠ Undo-by-deleting
Rewind = Recompute state as of an earlier Event
This distinction matters. Because the full log still exists, an application (or a developer, or an agent) can rewind, inspect an earlier state, and then choose to either:
Resume forward
→ continue applying the remaining Events as normal
Branch
→ issue new Commands from that point, creating a new
continuation of history instead of the original one
Rewinding shows you the past. It doesn’t erase it.
Stepping Through
Because the log is just an ordered list, moving through it one Event at a time is a simple index operation, not a special debugging feature bolted on afterward.
◀── step back step forward ──▶
Event 001 Event 002 [ Event 003 ] Event 004 Event 005
▲
current cursor
A step forward applies the next Event in the log to the current state:
State(cursor) + Event(cursor + 1) = State(cursor + 1)
A step back recomputes state as of the previous Event, the same way rewinding does, just one Event at a time:
State(cursor − 1) = fold(Event 001 ..= Event(cursor − 1))
This gives an application (or a developer tool built on top of it) the same experience as stepping through a debugger, except the “breakpoints” are the application’s own Commands and Facts:
Step back → Fact: DocumentSummarized
Step back → Command: Document::Summarize
Step back → Fact: DocumentCreated
Because every step is just a Command or Fact, stepping through history is also stepping through an audit trail. There is nothing else to reconstruct.
A step-through debugger for application behavior isn’t a separate feature. It’s the same Event log, read one entry at a time.
Snapshots
Replaying an entire log from the beginning is simple and correct, but for a long-running application it can become slow.
A snapshot solves this without changing anything about how replay, rewind, or stepping work conceptually:
State(0) + Events 001..500 = State at Event 500
│
▼
Snapshot("at 500")
Snapshot("at 500") + Events 501..N = Current State
A snapshot is just a cached checkpoint: a Model’s state at a known Event, saved so replay doesn’t need to start from empty state every time.
Replay without a snapshot:
Empty State + Events 001..10,000
Replay with a snapshot:
Snapshot(9,900) + Events 9,901..10,000
Snapshots are an optimization, not a change to the model of history. The Event log remains the single source of truth; a snapshot can always be regenerated by replaying from the beginning.
A snapshot is a shortcut through history, never a replacement for it.
Less Boilerplate
Traditional applications often maintain multiple representations of the same capability.
A feature might require:
Implementation
API endpoint
Schema
Documentation
AI tool definition
Logging
Permissions
Tests
Beverly’s Event system is designed to collapse much of that duplication.
One strongly typed Event definition can become the source for the representations the rest of the application needs.
Event Definition
│
┌────────────┼────────────┐
↓ ↓ ↓
Rust Types AI Tools Documentation
│ │ │
├────────────┼────────────┤
↓ ↓ ↓
Validation Permissions Telemetry
The goal isn’t to generate more code.
It is to stop writing the same information multiple times.
Events Are the Contract
Events create a shared vocabulary between every participant in the application.
Human ────────┐
│
Keyboard ─────┤
│
API ──────────┤
├──► Command ──► Controller ──► Model
│
AI Agent ─────┘
Model ──► Fact ──► View
│
├──► Agent
│
├──► Other Systems
│
└──► Event History
Commands tell the application what someone wants to happen.
Facts tell the application what actually happened.
The compiler makes sure those messages conform to their contracts.
OpenTelemetry makes their activity observable.
The Event history makes that activity replayable.
And the same vocabulary can be understood by both humans and AI agents.
Events turn application behavior into typed, observable, self-documenting vocabulary.
That is the foundation for Beverly’s larger idea:
One application. One vocabulary. Humans and AI use the same interface.
3.5 Controller
The Controller connects Messages to application behavior.
It answers a simple question:
When this Event occurs, what should happen?
A Controller maps an Event to one or more Model methods or other application actions.
#![allow(unused)]
fn main() {
controller! {
UserController {
User::Create => create_user,
User::Delete => delete_user,
}
}
}
The macro generates a Bevy Plugin. Each handler receives a reference to the
message type it handles:
#![allow(unused)]
fn main() {
fn create_user(message: &User::Create) {
info!(name = %message.name, "creating user");
}
fn delete_user(message: &User::Delete) {
info!(id = message.id, "deleting user");
}
}
Register the messages and install the generated plugin explicitly:
#![allow(unused)]
fn main() {
app.add_message::<User::Create>()
.add_message::<User::Delete>()
.add_plugins(UserController);
}
controller! routes messages only. It does not make a view emit messages or
invent application state transitions.
The Event defines the contract.
The Controller defines what the application does with that contract.
The Model owns the data and behavior.
Event
↓
Controller
↓
Action
↓
Model
Controllers are intentionally declarative.
You should be able to glance at one and understand the application’s behavior without tracing through a large amount of framework code.
controller! supports three composable route forms:
| Route | Behavior |
|---|---|
Message => handler | Calls handler(&Message); its return value is ignored, preserving single-handler behavior. |
Message => [first, next, last] | Runs actions sequentially, passing each successful output to the next action. |
Message => {left, right} | Runs independent branches through Bevy’s scoped compute pool, waits for all, and returns their outputs as a tuple. |
Sequences and fan-out groups can nest recursively. Groups must contain at least
one action; trailing commas are accepted. Composed actions return Result.
The first action receives &Message; later actions receive their predecessor’s
owned output. Fan-out outputs form a tuple in declaration order, not completion
order. Even a one-branch group produces a one-element tuple (value,).
Complete Example
use bevy::prelude::*;
use beverly::prelude::*;
event! { Document::Process { id: u32 } }
#[derive(Clone)]
struct LoadedDocument {
id: u32,
text: String,
}
fn load(message: &Document::Process) -> Result<LoadedDocument, &'static str> {
Ok(LoadedDocument { id: message.id, text: "Report content".to_owned() })
}
fn summarize(document: LoadedDocument) -> Result<String, &'static str> {
Ok(document.text)
}
fn index(document: LoadedDocument) -> Result<u32, &'static str> {
Ok(document.id)
}
fn save((summary, id): (String, u32)) -> Result<(), &'static str> {
info!(id, summary, "document processed");
Ok(())
}
controller! {
DocumentController {
Document::Process => [load, { summarize, index }, save],
}
}
fn main() {
let mut app = App::new();
app.add_message::<Document::Process>().add_plugins(DocumentController);
app.world_mut().write_message(Document::Process { id: 42 });
app.update();
}
Here load receives the message, the parallel branches receive independent
LoadedDocument values, and save receives (String, u32) after both succeed.
The compute pool is initialized on first use if the application has not
initialized it through Bevy’s normal task-pool setup.
Sequential Pipelines
Multiple actions can be composed into a series using an array:
#![allow(unused)]
fn main() {
controller! {
DocumentController {
Document::Process => [load, parse, summarize, save],
}
}
}
The actions execute in order.
Most importantly, the return value from one action becomes the input to the next action.
DocumentId
↓
Document::load
↓
Document
↓
Document::parse
↓
ParsedDocument
↓
Document::summarize
↓
Summary
↓
Document::save
↓
()
There is no special pipeline object or custom data-passing mechanism.
The Controller composes ordinary function signatures.
Conceptually:
#![allow(unused)]
fn main() {
fn load(message: &Document::Process) -> Result<LoadedDocument, Error>
fn parse(document: LoadedDocument) -> Result<ParsedDocument, Error>
fn summarize(document: ParsedDocument) -> Result<Summary, Error>
fn save(summary: Summary) -> Result<(), Error>
}
The output of each step must be compatible with the input of the next.
Sequential steps move values; intermediate values do not need to implement
Clone. Use ordinary functions or associated function paths, such as
DocumentModel::load. These are function calls, not Bevy systems with injected
Res, ResMut, or Commands parameters. Obtain application dependencies
explicitly through the message or values passed between actions.
If it isn’t, Rust catches the problem at compile time.
If the pipeline doesn’t type-check, it doesn’t build.
Efficient Data Flow
Rust’s ownership model also makes these pipelines extremely efficient.
A pipeline step receives the data it needs, performs its work, and passes the result to the next step.
The pipeline does not need to maintain a collection of long-lived objects waiting for a garbage collector to eventually clean them up.
Data can be moved through the pipeline with clear ownership and deterministic lifetime.
Step A
│
│ owns value
▼
Step B
│
│ owns value
▼
Step C
│
│ owns value
▼
Step D
When a value is no longer needed, its lifetime ends according to Rust’s ownership rules.
There is no tracing garbage collector periodically scanning the application’s object graph.
That matters for high-throughput applications.
Instead of building a large managed object graph and waiting for garbage collection, Beverly pipelines can move data through a predictable sequence of operations with deterministic resource management.
This is particularly valuable when processing:
- Streaming data
- Large datasets
- Telemetry
- AI inference results
- Documents
- Images
- Financial data
- Device data
The framework can orchestrate the pipeline while Rust handles the underlying memory management.
Data flows through the pipeline. It doesn’t have to live there.
This is one of the reasons Beverly can pursue high-performance application infrastructure without exposing developers to complicated memory-management APIs.
Errors Break the Chain
Controller pipelines use standard Rust Result.
There is no special Beverly error-flow abstraction to learn.
#![allow(unused)]
fn main() {
fn parse(document: Document)
-> Result<ParsedDocument, DocumentError>
}
If an action returns:
#![allow(unused)]
fn main() {
Ok(value)
}
the value continues to the next action.
If it returns:
#![allow(unused)]
fn main() {
Err(error)
}
the current chain stops.
Sequential stages can use a common error type or convert earlier errors into
the last stage’s error type through From, as with ordinary Rust ?.
Fan-out branches must share one error type. The composed route’s error must
implement Display: Beverly logs it with the controller and message type,
then continues processing later messages. The final successful value is
discarded; emit a follow-up message explicitly when the application needs a
new workflow boundary. A plain Message => handler route still ignores its
return value; use [handler] for a one-stage route with composed error logging.
Action A
↓
Action B
↓
Err(error)
↓
STOP
This uses a mechanism Rust developers already understand.
Beverly doesn’t need to reinvent Result, error propagation, or failure semantics.
Successful values flow forward. Errors stop the chain.
Parallel Fan-Out
Not every action needs to happen in sequence.
A Controller can fan an input out to multiple actions in parallel using braces:
#![allow(unused)]
fn main() {
controller! {
UserController {
User::Created => { notify_user_created, record_analytics, inform_agent },
}
}
}
Conceptually:
UserCreated
│
┌───────┼───────┐
↓ ↓ ↓
Notification Analytics Agent
The actions can execute independently.
Each branch above receives &User::Created and returns Result<Output, Error>.
Borrowing the same message does not require the message itself to be Clone.
For an owned intermediate value, fan-out clones the input for all but one
branch, so that input must implement Clone. Use Arc for shared read-only
data when cloning a large payload would be expensive.
Fan-out uses Bevy’s scoped compute pool and waits for every branch to finish.
Captured values and results must be Send; shared borrows must be Sync.
Native builds support simultaneous execution when pool workers are available.
Single-threaded browser builds preserve tuple and error semantics but do not
execute synchronous branch bodies simultaneously. Branches must not rely on
another branch running concurrently to make progress.
Branch completion order is unspecified. Outputs are joined in declaration order. If branches fail, all siblings are still allowed to finish; there is no rollback or sibling cancellation. The first error in declaration order is propagated and any following sequential stage is skipped. Protect shared mutable state explicitly and keep branch side effects independent.
This is useful when multiple systems need to react to the same Fact and there is no reason for them to wait on one another.
The distinction is deliberately visual:
#![allow(unused)]
fn main() {
[ A, B, C ] // series
{ A, B, C } // parallel
}
One means do these things in order.
The other means fan this work out.
Series and Parallel Can Be Combined
Series and parallel operations can be composed.
#![allow(unused)]
fn main() {
controller! {
DocumentController {
Document::Process => [load, { index, analyze, metadata }, save],
}
}
}
The conceptual flow is:
load
│
▼
┌──────┼──────┐
↓ ↓ ↓
index analyze metadata
└──────┼──────┘
↓
save
A parallel stage can produce a result that becomes the input to the next stage.
In this example, save receives (IndexOutput, AnalysisOutput, MetadataOutput)
as one tuple argument. For a sequence inside one branch, write:
#![allow(unused)]
fn main() {
controller! {
DocumentController {
Document::Process => [load, { [parse, summarize], index }, save],
}
}
}
That save receives (Summary, IndexOutput). Fan-out inside fan-out produces
nested tuples; it does not flatten them. Each nested sequence preserves its
own order while independent branches can overlap.
This means:
Series
↓
Parallel
↓
Series
can remain part of the same straightforward data-flow model.
The framework handles the orchestration.
The developer describes the relationships.
Type Safety Across the Pipeline
Because each action has a typed input and output, the entire pipeline can be checked before the application runs.
For example:
load()
→ Document
parse(Document)
→ ParsedDocument
summarize(ParsedDocument)
→ Summary
is valid.
But:
load()
→ Document
summarize(ParsedDocument)
→ Summary
cannot be connected directly.
The types don’t match.
Rust catches the incompatible pipeline at compile time.
This is one of the places where Beverly’s use of Rust becomes particularly powerful:
The compiler becomes a pipeline validator.
Developers don’t need to manually inspect every connection.
AI-generated pipelines don’t have to be trusted blindly.
The type system verifies that the data actually flows between the operations as described.
Async Is Explicit
Controller composition is synchronous from the route’s perspective: the
controller’s Update system waits for the pipeline and all parallel branches.
It accepts functions returning Result, not async fn or detached jobs. Long
CPU work can still block a UI update even when it uses multiple worker threads.
For long-running inference, network I/O, or cancellable work, use a worker and
explicit request/response messages rather than treating fan-out as background
execution.
Asynchronous operations are intentionally modeled differently.
An asynchronous operation is not treated as one magical Controller step that hides its lifecycle.
It is two operations:
- Issue the request.
- Listen for the response.
For example:
Command: Document::Process
↓
start work
↓
... async ...
↓
Fact: Document::Processed
The first operation initiates the work.
The resulting Fact represents the response.
Another Controller can listen for that Fact:
#![allow(unused)]
fn main() {
controller! {
DocumentController {
Document::Processed => save_document,
}
}
}
This keeps asynchronous behavior consistent with Beverly’s Event architecture.
There isn’t a hidden callback chain buried inside a single pipeline expression.
The application can see the request.
The application can see the response.
The Event history can see both.
The AI can see both.
Prefer Events for Long-Running Flows
Controllers are powerful, but they shouldn’t become enormous chains of application logic.
For short, tightly coupled operations, a series is ideal:
#![allow(unused)]
fn main() {
[A, B, C]
}
For longer or conceptually separate workflows, prefer breaking the chain with a new Event.
Instead of:
A → B → C → D → E → F → G → H
consider:
A → B → Fact
↓
C → D → Fact
↓
E → F
The Event becomes a natural boundary between stages.
This provides:
- Smaller Controllers
- Easier testing
- Clearer application behavior
- Better observability
- Natural asynchronous boundaries
- Better AI discoverability
- Easier replay and debugging
Use pipelines for composition. Use Events for boundaries.
Controllers as Application Wiring
The Controller is intentionally not where application logic lives.
It connects the pieces.
Event
│
▼
Controller
│
┌────┴────┐
↓ ↓
Series Parallel
│ │
└────┬────┘
↓
Model
The Model owns the domain behavior.
The Event defines the contract.
The Controller determines how those pieces connect.
That separation makes the Controller easy to read, easy to generate, and easy to test.
And because the connections are typed, Rust can validate both the logic and data flow before the application ever runs.
The Controller is the wiring diagram for your application.
You describe what connects to what.
Beverly handles the orchestration.
Rust makes sure the connections make sense.
Localize
localize() provides Beverly’s interface for localized text.
At its simplest, it turns a localization key into text:
#![allow(unused)]
fn main() {
text(localize("user.welcome"))
}
But localization in an AI application can be more than a static dictionary lookup.
An application may need to:
- Switch languages at runtime.
- Generate alternative wording.
- Ask an LLM to produce localized text.
- Review previous generated versions.
- Step forward and backward through alternatives.
- Rewind to an earlier version.
- Keep the original semantic key independent from its presentation.
- Allow humans and AI agents to participate in the same localization workflow.
Beverly therefore treats localization as a stateful presentation layer while keeping the underlying semantic identity stable.
The key identifies the meaning. The locale determines the language. The translation provides the presentation.
Basic Usage
The simplest form is a localization key:
#![allow(unused)]
fn main() {
text(localize("user.welcome"))
}
The localization system resolves the key using the application’s current language.
For example:
user.welcome
might resolve to:
English:
Welcome back.
Spanish:
Bienvenido de nuevo.
French:
Bon retour.
The application does not need to change its UI code when the language changes.
#![allow(unused)]
fn main() {
text(localize("user.welcome"))
}
remains the same.
Localization Keys
A localization key represents meaning rather than a particular string.
#![allow(unused)]
fn main() {
localize("user.welcome")
}
is preferable to embedding:
#![allow(unused)]
fn main() {
"Welcome back."
}
throughout the application.
The key becomes the stable vocabulary shared between:
- UI code
- Translation files
- Language selection
- Accessibility text
- AI-generated alternatives
- Tests
- Documentation
For example:
#![allow(unused)]
fn main() {
localize("user.save")
localize("user.save.label")
localize("user.save.description")
localize("user.save.error")
}
The application can therefore change its language without changing its component structure.
Current Language
The localization system maintains the application’s current locale.
For example:
#![allow(unused)]
fn main() {
locale()
}
might return:
en-US
Changing the locale updates localized content throughout the application.
#![allow(unused)]
fn main() {
set_locale("es-ES")
}
All components using localize() can then resolve their keys using the new language.
The important point is that components do not need to manually update themselves.
Current Locale
↓
localize()
↓
Localized Text
↓
View
Change the language once. Let the interface follow the state.
Swapping Languages
Language switching should be a normal application operation.
For example:
#![allow(unused)]
fn main() {
Command::Locale::Set("fr-FR")
}
The application can connect that Command to its localization state:
#![allow(unused)]
fn main() {
controller! {
Command::Locale::Set => Locale::set,
}
}
After the Model changes locale, every localize() expression resolves against the new locale.
This means a language selector does not need to manually update every component.
#![allow(unused)]
fn main() {
select()
.label(localize("language.label"))
.aria(localize("language.description"))
.bind(Locale::current)
.children([
option("en-US").children(text("English")),
option("es-ES").children(text("Español")),
option("fr-FR").children(text("Français")),
])
}
The application changes one piece of state.
The View follows it.
Language Is State
The current language belongs to application state rather than individual components.
For example:
#![allow(unused)]
fn main() {
struct Locale {
language: String,
}
}
The UI reads the current language through the localization system.
This avoids patterns such as:
#![allow(unused)]
fn main() {
button().text(localize("user.save"))
text(localize("user.description"))
modal_title(localize("user.title"))
}
The language should not be repeated throughout the interface.
Instead:
Locale
↓
Application State
↓
localize()
↓
Entire View
Locale is application state. Localization is its projection into language.
Rewind
Localization can maintain a history of changes and generated alternatives.
That makes it possible to rewind to an earlier localized state.
#![allow(unused)]
fn main() {
localize("document.summary")
.rewind()
}
Rewind returns the localization state to the previous point in its history.
This is particularly useful when an LLM has generated several alternatives and the application wants to return to an earlier version.
Conceptually:
Version 1
↓
Version 2
↓
Version 3
↓
Version 4
Rewind can move backward:
Version 4
↓
Version 3
The semantic localization key remains unchanged.
Only the presentation state changes.
Step Back
step_back() moves one position backward through localization history.
#![allow(unused)]
fn main() {
localize("document.summary")
.step_back()
}
For example:
Original
↓
Alternative 1
↓
Alternative 2
↓
Alternative 3
Calling step_back() from Alternative 3 produces:
Alternative 2
Calling it again produces:
Alternative 1
This is useful when reviewing generated language without discarding the history.
Step Forward
step_forward() moves one position forward through the existing history.
#![allow(unused)]
fn main() {
localize("document.summary")
.step_forward()
}
For example:
Alternative 1
↓
Alternative 2
↓
Alternative 3
If the current version is Alternative 1, stepping forward selects Alternative 2.
This creates a simple navigation model:
step_back() ← Current → step_forward()
Rewind vs Step Back
The two operations have different purposes.
step_back() moves one position:
#![allow(unused)]
fn main() {
localize("document.summary")
.step_back()
}
rewind() returns to an earlier known point:
#![allow(unused)]
fn main() {
localize("document.summary")
.rewind()
}
Step navigation is useful for reviewing history.
Rewind is useful for abandoning later changes and returning to the previous stable state.
The localization system should preserve enough history to make these operations predictable.
LLM Alternatives
One of the important differences between Beverly localization and a traditional translation system is that localized text can have multiple valid representations.
For example:
Key:
document.summary
could produce:
"The document contains three sections."
An LLM might produce alternatives such as:
"This document contains three sections."
"There are three sections in this document."
"This document is divided into three sections."
These alternatives can all represent the same underlying meaning.
The localization key does not change.
Only the presentation does.
LLM Hook
The localization system should provide a clean hook for attaching an LLM to text generation.
Conceptually:
#![allow(unused)]
fn main() {
localize("document.summary")
.generate_with(llm)
}
The LLM receives the semantic localization request and relevant context.
For example:
#![allow(unused)]
fn main() {
localize("document.summary")
.generate_with(llm)
.context(document)
}
The result becomes another localization candidate.
The important architectural boundary is that the LLM does not replace the localization system.
It provides an alternative representation.
Localization Key
↓
Localization Context
↓
LLM
↓
Alternative Text
↓
Localization History
↓
View
The LLM generates language. The localization system owns the language state.
Alternative Text
Generated alternatives should remain associated with the original localization key.
For example:
#![allow(unused)]
fn main() {
localize("document.summary")
}
might have:
Original:
The document contains three sections.
Alternative 1:
This document contains three sections.
Alternative 2:
The document is divided into three sections.
Alternative 3:
There are three sections in this document.
The application can navigate between them without changing the underlying semantic identity:
document.summary
This is important for AI-generated interfaces because it separates what the application wants to communicate from how that idea is expressed.
Language + LLM
The same localization key can be passed through the LLM in different languages.
For example:
#![allow(unused)]
fn main() {
localize("document.summary")
.locale("es-ES")
.generate_with(llm)
}
The result might be:
El documento contiene tres secciones.
The application can then generate another alternative:
Este documento está dividido en tres secciones.
The localization history remains associated with the same key and locale.
Conceptually:
document.summary
│
┌──────────┴──────────┐
↓ ↓
en-US es-ES
│ │
┌─────┼─────┐ ┌─────┼─────┐
↓ ↓ ↓ ↓ ↓ ↓
v1 v2 v3 v1 v2 v3
This provides a clean foundation for multilingual AI-generated interfaces.
Human Review
Generated alternatives should not automatically replace an approved translation simply because an LLM produced them.
Instead, the application can treat generated text as a candidate.
For example:
#![allow(unused)]
fn main() {
localize("document.summary")
.generate_with(llm)
.candidate()
}
The application or user can then review the result.
Current
↓
Generate
↓
Candidate
↓
Review
↓
Accept / Reject
If accepted, the candidate becomes the current localized representation.
If rejected, the application can step back or select another candidate.
This makes AI generation compatible with ordinary application state and review workflows.
Localization History
Localization history can be thought of as a sequence of presentations associated with a key.
Key: document.summary
History:
──────────────────────────────────────
v1 "The document contains three sections."
v2 "This document contains three sections."
v3 "The document is divided into three sections."
v4 "There are three sections in this document."
──────────────────────────────────────
↑
Current
The history makes generated language inspectable and reversible.
It also means the application does not have to treat every generated result as destructive.
Generate without losing what came before.
Events
Localization changes can participate in Beverly’s Event system.
For example:
#![allow(unused)]
fn main() {
event! {
Locale::Changed {
locale: String
}
Localization::Generated {
key: String,
locale: String
}
Localization::Accepted {
key: String,
locale: String
}
Localization::Rewound {
key: String
}
}
}
This makes language changes and generated alternatives observable.
An agent can listen for localization events just like any other application event.
For example:
Localization::Generated
↓
Agent observes
↓
Agent evaluates
↓
Localization::Accepted
The same event vocabulary can therefore support humans and AI.
Telemetry and History
Because localization changes can be represented as Events, the same event history used elsewhere in Beverly can provide visibility into language changes.
For example:
Locale Changed
↓
Text Generated
↓
Alternative Created
↓
Alternative Accepted
↓
UI Updated
This makes it possible to understand how the current text came to exist.
Combined with Beverly’s Event history, an application can inspect, replay, or rewind localization changes.
The exact behavior of rewind across external LLM calls should remain explicit: replaying an application event does not necessarily reproduce the exact same external model response unless that response was recorded.
Localization and Accessibility
Localization should apply equally to accessibility text.
For example:
#![allow(unused)]
fn main() {
button().text(localize("user.save"))
}
The same keys can resolve into the active language.
This prevents an application from accidentally translating visible content while leaving accessible descriptions in another language.
Localization therefore applies to:
- Visible text
- Labels
- ARIA descriptions
- Tooltips
- Error messages
- Success messages
- Placeholder text
- Button text
- Navigation
- System messages
If the user experiences it as language, it should be localizable.
Localization Does Not Own Application Meaning
localize() should never become a replacement for the Model.
For example:
#![allow(unused)]
fn main() {
localize("user.status")
}
does not determine whether a user is active.
The Model determines:
#![allow(unused)]
fn main() {
User::status
}
Localization determines how that status is presented:
#![allow(unused)]
fn main() {
localize("user.status.active")
}
The distinction remains:
Model
↓
Meaning
↓
Localization
↓
Language
↓
View
This is especially important when LLMs are involved.
An LLM can help express meaning.
It should not silently redefine the application’s underlying meaning.
Higher-Level Localization Functions
Localization can also be wrapped by ordinary Rust functions.
For example:
#![allow(unused)]
fn main() {
fn user_status(status: UserStatus) -> LocalizedText {
match status {
UserStatus::Active =>
localize("user.status.active"),
UserStatus::Pending =>
localize("user.status.pending"),
UserStatus::Inactive =>
localize("user.status.inactive"),
}
}
}
The application can then use:
#![allow(unused)]
fn main() {
text(user_status(user.status()))
}
This keeps domain vocabulary in ordinary Rust while leaving language resolution to Beverly.
The framework provides localization. The application provides the vocabulary.
What Localize Does Not Do
The localization system does not own:
- Application state
- Domain meaning
- Business rules
- User permissions
- LLM decision-making
- Translation approval
- Content policy
- Application workflow
It provides the language layer between application meaning and user presentation.
LLMs can participate in that layer, but they remain another source of generated language rather than becoming the source of truth for application state.
The Model
The complete conceptual model is intentionally simple:
Application Meaning
↓
Localization Key
↓
Locale
↓
Translation / LLM
↓
Localization History
↓
View
The locale can change.
The text can change.
An LLM can generate alternatives.
The user can step backward or forward.
The application can rewind.
But the semantic identity of the content remains stable.
One key. Many languages. Many representations. Reversible history.
The Principle
Traditional localization answers:
“What string corresponds to this key in this language?”
Beverly’s localization system can answer a broader question:
“How should this application meaning be expressed, in this language, right now?”
That distinction matters for AI-native applications.
A human translator can provide the text.
A translation service can provide the text.
An LLM can generate alternatives.
A user can review them.
An agent can evaluate them.
And the application can move backward and forward through the resulting history.
All of those operations still operate on the same underlying localization key.
Create. Resolve. Generate. Review. Navigate. Accept. Rewind.
The key represents meaning. The locale represents language. The history represents alternatives. The LLM provides another way to express the meaning.
AI Workloads, Background Workers & Channels
Beverly does not need a special AI programming model. An AI workload is simply another piece of application work. It may take a long time to complete, it may produce results incrementally, and it may need to communicate with the rest of the application while it is running, but the underlying problem is not unique to AI. Rust, Bevy, and ordinary application architecture already provide the tools needed to handle it.
The most important rule is to keep long-running work away from the main UI loop. Inference, RAG retrieval, embedding generation, document processing, network requests, and tool calls can all take enough time to make a UI unresponsive if they are performed directly as part of the main application work. Instead, run that work in the background and communicate the results back to the application.
A simple architecture looks like this:
AI Worker → Channel → Model → View
The worker performs the expensive work, a standard Rust channel carries the results back to the application, and the Model becomes the owner of those results. On the next Bevy tick, the View reflects the updated Model state automatically. The AI code does not need to know how the UI is rendered, and the UI does not need to know how inference was performed.
Background Workers
Bevy already provides mechanisms for running work outside the main application loop, while Rust provides standard concurrency primitives such as channels. Beverly should use these existing tools rather than recreate them as another abstraction. Bevy provides the runtime, Rust provides the concurrency primitives, and the application can use whatever AI library, local model, remote service, vector database, or other technology makes sense for the workload.
For example, an AI worker might perform inference and send partial results through a channel:
#![allow(unused)]
fn main() {
enum AiMessage {
Token(String),
Complete,
Error(String),
}
}
As the worker receives tokens, it can send them back to the application:
#![allow(unused)]
fn main() {
tx.send(AiMessage::Token(token))?;
}
The application receives those messages and updates the Model:
#![allow(unused)]
fn main() {
while let Ok(message) = rx.try_recv() {
match message {
AiMessage::Token(token) => {
chat.append_response(&token);
}
AiMessage::Complete => {
chat.set_status(ChatStatus::Complete);
}
AiMessage::Error(error) => {
chat.set_error(error);
}
}
}
}
The important part is what happens after the message is received. The response is immediately saved into the Model, and on the next tick the View sees the new state and updates automatically. There is no need to manually find a text component and update it every time a token arrives. Streaming is simply the normal Model → View relationship happening repeatedly as new data becomes available.
Streaming AI Responses
Streaming therefore does not require a special UI architecture. It is simply a case where the Model changes incrementally instead of all at once.
Suppose the Model contains the current response:
#![allow(unused)]
fn main() {
struct Chat {
response: String,
}
}
The Model can expose ordinary getters and setters:
#![allow(unused)]
fn main() {
impl Chat {
fn get_response(&self) -> &str {
&self.response
}
fn set_response(&mut self, response: String) {
self.response = response;
}
fn append_response(&mut self, token: &str) {
self.response.push_str(token);
}
}
}
The straightforward getter/setter approach makes the architecture particularly clear. Conceptually, each new token becomes part of the existing response:
#![allow(unused)]
fn main() {
chat.set_response(
chat.get_response().to_owned() + &token
);
}
For an efficient implementation, the Model can instead expose an append operation:
#![allow(unused)]
fn main() {
chat.append_response(&token);
}
Both approaches express the same architectural principle: the AI response belongs to the Model. The append method is simply a more efficient way of implementing the same state transition without repeatedly constructing a new String.
The View only needs to read the current state:
#![allow(unused)]
fn main() {
text(chat.get_response())
}
The AI worker should not reach into the UI and call set_text() every time it receives a token. set_text() is a normal component-level setter and is useful when configuring or directly modifying a text component:
#![allow(unused)]
fn main() {
text("Hello")
.set_text("Hello, world!");
}
That is different from application state. For application data, the response belongs to the Model, and the normal path remains AI result → Model setter → View. This distinction becomes increasingly valuable as an application grows because the same response can be displayed in multiple places, persisted, inspected, or consumed by other parts of the application without being trapped inside a UI component.
AI Work Is Still Application Work
Once this pattern is established, more complicated AI workloads do not require a different architecture. A RAG workflow might generate an embedding, search a vector index, retrieve documents, construct context, invoke an LLM, and stream the resulting response. Those steps can all happen in ordinary Rust code, with the channel carrying meaningful results back to the application as they become available.
For example:
#![allow(unused)]
fn main() {
enum AiMessage {
Searching,
ContextFound(Vec<Document>),
Token(String),
Complete,
Error(String),
}
}
The application can translate those messages into Model state:
#![allow(unused)]
fn main() {
while let Ok(message) = rx.try_recv() {
match message {
AiMessage::Searching => {
chat.set_status(ChatStatus::Searching);
}
AiMessage::ContextFound(documents) => {
chat.set_context(documents);
}
AiMessage::Token(token) => {
chat.append_response(&token);
}
AiMessage::Complete => {
chat.set_status(ChatStatus::Complete);
}
AiMessage::Error(error) => {
chat.set_error(error);
}
}
}
}
The View remains ordinary Beverly code:
#![allow(unused)]
fn main() {
chat_view(chat)
}
Nothing about RAG requires the UI to become an AI-specific UI. The AI performs the work and produces results; the application decides how those results become state and how that state is presented.
This is an important Beverly principle: AI should fit into the application, rather than forcing the application to become an AI framework.
AI Tool Calls
The same principle applies when an AI needs to take an action. Suppose the application already exposes a Command for retrieving orders:
#![allow(unused)]
fn main() {
Command::Orders::Get {
user_id: user.get_id(),
}
}
A human interface might trigger that Command through a Button. An API could trigger the same Command from an external request, and an AI could determine that retrieving the orders is the appropriate next step. There should not need to be three different implementations of “get orders” simply because there are three different sources of the request.
The Command is the application’s existing vocabulary for that action, so every participant should use it.
This is where JEV fits naturally into the architecture.
JEV Chooses From Existing Commands
JEV does not create application behavior. The application defines its available Commands, and JEV provides a structured way for an AI to choose between those Commands.
For example, an application might already define:
#![allow(unused)]
fn main() {
Command::Orders::Get
Command::Orders::Cancel
Command::Orders::Refund
Command::User::Notify
}
JEV can choose from those existing actions:
#![allow(unused)]
fn main() {
Jev::choice("next_action", [
Command::Orders::Get,
Command::Orders::Cancel,
Command::Orders::Refund,
])
}
The AI is therefore operating inside the application’s existing vocabulary. It is not generating arbitrary code, inventing an API, or creating new application capabilities at runtime. The developer defines what the application is capable of doing, while JEV provides the structured decision that determines which of those capabilities should be used.
This distinction is particularly useful for reasoning about agents. An agent does not necessarily need its own set of tools, execution framework, or application architecture. The application already has actions. The AI needs a way to observe what is happening, decide what should happen next, and request one of the actions that the application already understands.
That gives us a simple principle:
Commands define what the application can do. JEV chooses what to do.
Once JEV has selected a Command, the request follows the same Controller path as any other Command:
JEV
↓
Command
↓
Controller
↓
Model
↓
View
This shared path is also what makes the permission model powerful. The Model is the authoritative owner of application state and the rules governing whether that state is allowed to change. Because requests from humans, APIs, automations, and AI all ultimately reach the same Model, they all encounter the same permission and domain rules.
If the Model determines that an operation is not allowed, it can reject the state transition regardless of who requested it. JEV cannot grant itself additional capabilities simply by choosing a Command, because choosing a Command is not the same thing as being authorized to perform it. The Command expresses the requested action; the Model decides whether that action is valid and permitted against the current application state.
For example, an AI may choose:
#![allow(unused)]
fn main() {
Command::Orders::Refund {
order_id: order.get_id(),
}
}
The Controller connects that Command to the appropriate Model operation. The Model then makes the authoritative decision about whether the refund is allowed:
#![allow(unused)]
fn main() {
orders.refund(order.get_id())?;
}
The Model can consider whatever rules belong to the application’s state and domain: whether the order exists, whether it has already been refunded, whether it is in a refundable state, whether the operation is permitted for the current actor, or any other invariant the application requires.
This means the permission layer does not need to trust the source of the request. It only needs to enforce the rules at the point where state changes.
That gives Beverly an important security property: the same Model rules apply regardless of whether the request came from a human or an AI.
The Controller can still handle application-level concerns such as orchestration, confirmation flows, rate limiting, auditing, and other policies that belong outside the Model. But the Model remains the final authority over its own state and the domain rules governing how that state can change.
JEV therefore does not become a privileged pathway around the application. It is simply another way to request an existing Command, and that Command must still pass through the same application machinery as every other request.
AI and the Event Vocabulary
This becomes even more powerful when combined with Beverly Events. The application already has a vocabulary describing things that happened and things that can be requested.
Facts describe things that have happened:
#![allow(unused)]
fn main() {
User::Created
Order::Cancelled
Document::Indexed
}
Commands describe things that can be requested:
#![allow(unused)]
fn main() {
User::Create
Order::Cancel
Document::Index
}
An AI can listen to Facts, reason about the current state, and choose an existing Command through JEV. The resulting Command then enters the same Controller that handles Commands from every other source.
Because JEV operates through this existing Event system, AI decisions are automatically part of the application’s event history. Beverly does not need a separate AI logging mechanism, a separate replay system, or additional instrumentation just to understand what an agent did. The JEV decision, the resulting Command, and the subsequent application Events can participate in the same history as everything else.
This makes rewinding and replaying AI behavior a natural consequence of the architecture. If the application records its Events, an AI decision that resulted in a Command is already part of that Event stream. Replaying the history can therefore reproduce the sequence of decisions and resulting actions without requiring a second AI-specific development effort.
The same mechanism also makes AI behavior inspectable. A developer can look at the event history and see what happened, which Command JEV selected, what state the application was in, and what happened afterward. When the event system supports rewind and replay, those capabilities apply to AI behavior automatically because the AI is participating in the same event system.
In that sense, observability and replay are not features that have to be bolted onto the agent later. They are inherited from the application’s existing architecture.
An agent can therefore be understood as a participant in the application’s existing event vocabulary. It listens to what happened, makes a decision, and requests something that the application already knows how to do. The Model remains the authority over whether the requested state transition is actually allowed.
Listen → Decide → Command
JEV provides the structured decision, the Controller provides the application wiring, the Model provides the state and enforces its rules, and the Event system provides the history through which those decisions can be observed, replayed, and rewound. Nothing about this requires a second application architecture for AI.
Tool Results Become Model State
Once a Command runs, its result follows the same application path as any other application data. If an AI requests the user’s orders, for example, the resulting data can simply become Model state:
#![allow(unused)]
fn main() {
orders.set_items(result);
}
The View can then render those orders:
#![allow(unused)]
fn main() {
table(orders.get_items())
}
The AI does not need to update the table directly. It updates application state, and the View follows that state.
This means AI-generated data, database data, user-entered data, and data returned by external services can all participate in the same Model → View relationship. The source of the data may be completely different, but once it enters the Model, the rest of the application does not need a special mechanism to display it.
The same principle applies to authorization. If the result would cause a state transition, it goes through the Model’s rules rather than creating a separate AI-specific path. The Model remains the place where the application decides what is valid and what is permitted.
AI, JEV, and Beverly
The resulting architecture deliberately keeps each responsibility small. Rust provides concurrency, channels, types, and resource management. Bevy provides the runtime and mechanisms for background execution. AI code performs inference, RAG, retrieval, tool calls, or whatever workload the application requires. Channels carry results from that background work into the application.
The Model owns application state and the rules governing how that state may change. Commands define the actions the application can request, while the Controller connects those actions to the rest of the application. JEV sits alongside these pieces as the structured decision layer for AI: it can choose from the Commands that the application already exposes, but it does not bypass the Model’s rules.
Because JEV participates in the same Event system as the rest of the application, its decisions also inherit the application’s existing observability, history, replay, and rewind capabilities. There is no separate AI event log to maintain and no additional replay architecture to build simply because an AI is making decisions.
This means a chatbot, RAG system, tool-using agent, streaming inference pipeline, or JEV-driven agent can all be built using the same underlying architecture.
There is no need to turn Beverly into an AI framework to accomplish this. The AI simply becomes another participant in the application.
The worker does the work, the channel carries the result, the Model owns the state and decides whether state changes are allowed, JEV chooses from existing Commands, the Controller connects the action, the Event system records the decision and its consequences, and Beverly reflects the resulting state.
That is the goal: AI should use the application’s architecture rather than replacing it.
Components Overview
API status: The executable component API is the fluent
app().children/UiElementsurface and the concrete component reference pages. Some older composition examples in this book are design sketches; conceptual helpers are labeled as planned and should not be copied as compiling code.
Components Overview
Beverly components are the reusable building blocks that sit on top of Bevy’s ECS and layout system. They are designed to feel native in Rust applications while still being themeable, accessible, and easy to compose into larger screens.
Component model
A typical Beverly widget is a small stateful unit with these concerns:
- semantic structure and labels
- layout and sizing rules
- interaction state and keyboard focus
- theming via design tokens
- paint and material treatment when needed
This matches the pattern used in systems like block_studio: UI widgets are built from a small shared surface model, then specialized with different semantic and visual treatments. Title and text follow the same rule: they are lightweight content primitives that inherit the app’s typography and surface rules instead of carrying their own isolated styling system.
Composition model
Beverly components are meant to be composed into app shells, form sections, toolbars, panels, and data surfaces. The best results usually come from building the screen in layers:
- start with layout primitives such as stacks, grids, containers, and panels
- add semantic content blocks like titles, text, cards, and badges
- add interaction primitives like buttons, inputs, toggles, and selects
- place contextual controls such as dropdowns, modals, and tooltips where they help the task
- apply motion, effects, and visual emphasis only where they improve clarity
Title
Titles are the high-emphasis text primitive for screens, panels, cards, and section headers. Use them when you need a clear hierarchy marker that tells the user what area they are looking at.
In practice, titles work best when they are short, direct, and placed near the top of the relevant surface. The implementation pattern from block_studio is to treat title text as a reusable semantic block rather than a one-off label, so the same component can be used across app shells, cards, and page headers.
Text
Text is the default body-content primitive. Use it for descriptions, helper copy, metadata, and any other supporting content that should inherit the app’s global font and scale.
Text should stay flexible: the same primitive can render dense copy in a card, supporting notes under a title, or lightweight explanatory content in a panel. In block_studio-style composition, text is usually added as a child of a larger surface so it follows the same layout, spacing, and typography decisions as the rest of the interface.
Composition pattern
#![allow(unused)]
fn main() {
use bevy::prelude::*;
use beverly::prelude::*;
fn build_dashboard(mut commands: Commands) {
commands.spawn((
NodeBundle::default(),
BeverlyStack::vertical(),
)).with_children(|parent| {
parent.spawn(BeverlyCard::new("Summary")).with_children(|card| {
card.spawn(text("Realtime metrics and AI actions"));
});
parent.spawn(BeverlyButton::primary("Open report"));
});
}
}
Recommended usage
- Start with layout primitives such as stacks and containers.
- Add semantic content blocks such as cards and badges.
- Then layer in interactive controls such as buttons, toggles, and inputs.
- Finally apply rendering effects such as shadows, gradients, or glass surfaces only where they add clarity.
Catalog
- buttons for primary actions
- title for section and page hierarchy
- text for supporting copy and body content
- inputs and selects for editable data
- modals and toasts for important feedback
- tables and pagination for dense data views
- navigation, tabs, and search for multi-surface applications
- avatars, badges, and dividers for compact interface signals
Each component should remain legible in light and dark themes and degrade gracefully when motion or contrast settings are reduced.
Button
button() starts a primary BeverlyButton builder. Add its visible content with
.text(...) or .icon(...), attach an application callback with .on("click", ...),
or provide an ordered set of ButtonChild values with .children(...).
For ordinary text content elsewhere in a Beverly app, use text("..."). It
creates a Bevy Text component that is picked up by Beverly’s default
typography pipeline.
#![allow(unused)]
fn main() {
use bevy::prelude::*;
use beverly::prelude::*;
fn save_document(_commands: &mut Commands, _button: Entity) {
// Update application state or write an application message here.
}
app()
.children([button().text("Save").icon("save").on("click", save_document)])
.run();
}
BeverlyPlugin installs ButtonPlugin. If the component is used without the
aggregate plugin, add ButtonPlugin yourself.
Live Demo
Open the demo in a separate tab. The embedded canvas supports pointer interaction; use the top-right control to switch between light and dark themes.
To regenerate the bundle locally, install the wasm32-unknown-unknown target
and Trunk 0.21.14, then run:
mdbook build docs
bash web/button-demo/build.sh
The Pages workflow builds and publishes this bundle automatically.
Live Demo: Buttons
Open the demo in a separate tab.
Content
The convenience constructors remain available when the label is known at the call site:
#![allow(unused)]
fn main() {
let primary = BeverlyButton::primary("Save");
let danger = BeverlyButton::danger("Delete").disabled(true);
let text_only = BeverlyButton::text_button("Learn more");
}
The fluent form is useful when content is assembled conditionally:
#![allow(unused)]
fn main() {
let save = button().text("Save");
let icon_only = button().icon("save");
let custom = button().children([
ButtonChild::icon("save"),
ButtonChild::text("Save document"),
ButtonChild::custom(spawn_badge),
]);
}
Custom children are setup functions with signature
fn(&mut ChildSpawnerCommands, Color):
#![allow(unused)]
fn main() {
fn spawn_badge(parent: &mut ChildSpawnerCommands, color: Color) {
parent.spawn((text("NEW"), TextColor(color)));
}
}
The first text child is used as the accessible name for a custom-content button. Icon-only buttons should add a text child or provide an application accessible-name component rather than relying on the icon name.
Activation
.on("click", save_document) accepts a function pointer with the signature
fn(&mut Commands, Entity). This keeps application behavior outside the
component while allowing the button to invoke the command when it is pressed
by pointer or keyboard activation:
#![allow(unused)]
fn main() {
fn open_settings(commands: &mut Commands, button: Entity) {
commands.entity(button).insert(SettingsRequested);
}
let settings = button().text("Settings").on("click", open_settings);
}
Supported event names include click, clickdown/pointerdown,
clickup/pointerup, mouseenter/pointerenter,
mouseleave/pointerleave, mousemove/pointermove, doubleclick,
dragstart, dragmove, dragend, dragcancel, longpress, scroll,
load, and unload.
load runs when the button is added to the world. unload runs when its
BeverlyButton component is removed. Pointer and gesture handlers use the
same callback signature: fn(&mut Commands, Entity).
Disabled buttons do not invoke pointer, click, or gesture handlers. Buttons do
not emit application events; all application behavior must be attached
explicitly with .on(...).
Content
Content components give a Beverly view its internal structure. They decide how information is grouped, ordered, and related so that a screen remains readable as its content grows.
The goal is not to make every view look the same. It is to give each piece of content a clear responsibility and a predictable place in the composition.
Content
│
├── Container
│ └── Defines the boundary of a content region
│
├── Row / Column
│ └── Arrange related content along a clear axis
│
├── Stack
│ └── Sequences content with consistent spacing
│
└── Children
└── Express the relationship between a parent and its content
Content components shape the View, but they do not own application state or business rules. The Model remains the source of truth, while the View uses these primitives to present that state and collect interaction.
When choosing a content primitive, start with the relationship you need to express:
- use a container to establish a boundary and shared context
- use a row or column to make an explicit horizontal or vertical arrangement
- use a stack when the main concern is sequencing items with consistent space
- use children to make parent-child composition clear
This keeps the structure easy to read, easy to change, and local to the part of the interface it describes.
Container
Containers define the boundary of a content region. They give related children a shared width, alignment, and context so that a view can grow without becoming a loose collection of unrelated elements.
When to use
Use a container when you need to:
- establish the readable boundary of a page or panel
- align a heading, body, and actions to the same content edge
- group a section that has one clear purpose
- keep internal rows, columns, or stacks inside a predictable region
A container is especially useful at the point where page structure becomes local content structure. Use a row or column inside it to describe how that content is arranged.
Composition model
A container should provide context without hiding the hierarchy of its children. Give it a clear purpose, then let rows, columns, stacks, and individual components express the relationships inside that boundary.
Container
├── Heading
├── Content region
│ ├── Row
│ └── Column
└── Actions
The container owns the region’s boundary and alignment. Its children remain responsible for their own semantics, presentation, and interaction.
Example structure
#![allow(unused)]
fn main() {
use bevy::prelude::*;
use beverly::prelude::*;
fn build_workspace_panel(mut commands: Commands) {
commands.spawn((
NodeBundle::default(),
BeverlyContainer::new(),
)).with_children(|container| {
container.spawn(BeverlyTitle::new("Workspace"));
container.spawn(text("Review the latest activity."));
container.spawn(BeverlyButton::primary("Open workspace"));
});
}
}
The container makes these elements read as one panel without deciding what the workspace data means or what happens when the button is pressed. The Model owns the data, and the View presents it within the container’s boundary.
Guidance
- give each container a clear content boundary and purpose
- align related headings, content, and actions to the same edge
- keep internal structure explicit with rows, columns, or stacks
- avoid stretching dense content across the full viewport
- do not use a container as a substitute for semantic grouping or state ownership
Card
Live Demo: Card
Open the demo in a separate tab.
Live Demo: Cards
Open the demo in a separate tab.
A Card is a container for related content.
It provides a visual boundary around a group of components so that information, controls, and other content can be presented as a single coherent unit. A Card does not own the meaning of its contents, does not own application state, and does not decide what the application should do with the information inside it.
Cards are intentionally simple. They provide structure, presentation, composition, and optional interaction without becoming an application-specific abstraction.
The Card provides the surface. Its contents provide the meaning.
Basic Card
The simplest Card contains one piece of content.
#![allow(unused)]
fn main() {
card()
.children(text(localize("user.profile")))
}
More commonly, a Card contains several components:
#![allow(unused)]
fn main() {
card()
.children([
text(localize("user.name")),
text(localize("user.email")),
button().text(localize("user.edit")),
])
}
The Card does not need to understand what the content represents. It simply provides a container in which the components can be composed.
This makes the Card useful for dashboards, settings, profiles, documents, agent activity, data records, notifications, and almost any other interface where related information benefits from a visual boundary.
Composition
children() is the fundamental composition mechanism.
A Card can contain a single value:
#![allow(unused)]
fn main() {
card()
.children(text(localize("user.profile")))
}
Or many values:
#![allow(unused)]
fn main() {
card()
.children([
text(localize("user.profile")),
text(localize("user.description")),
button().text(localize("user.edit")),
])
}
Those children can themselves be Cards or any other Beverly components:
#![allow(unused)]
fn main() {
card()
.children([
card()
.children(text(localize("user.account"))),
card()
.children(text(localize("user.preferences"))),
])
}
There is no separate composition system for Cards. A Card uses the same children() primitive as the rest of Beverly.
Build the primitive once. Compose it everywhere.
Named Sections
Cards implement .header(...), .body(...), and .footer(...) as top,
middle, and bottom content regions. Regions always render in that order,
regardless of builder-call order. Each accepts one UiElement or an iterable
of elements; repeated calls append to the same region.
#![allow(unused)]
fn main() {
card()
.header(text(localize("user.profile")))
.body([
text(localize("user.name")),
text(localize("user.email")),
])
.footer(button().text(localize("user.edit")))
}
These sections are implemented by Beverly as child entities carrying
CardHeader, CardBody, and CardFooter markers. They do not replace
children(): when sections are used, ordinary children join the body before
explicit body content. The body grows to fill available card height, keeping
the header at the top and footer at the bottom. Without sections, ordinary
children retain their existing direct-child layout.
The concrete fluent constructor is UiElement::card(), available through
beverly::prelude::*:
#![allow(unused)]
fn main() {
UiElement::card()
.header(text("Account"))
.body(text("Account information"))
.footer(button().text("Save"))
}
The same sections can accept one value or multiple values:
#![allow(unused)]
fn main() {
card()
.header([
icon("user"),
text(localize("user.profile")),
])
.body([
text(localize("user.name")),
text(localize("user.email")),
badge(localize("user.active")),
])
}
This gives applications a useful vocabulary for common layouts while keeping the underlying composition model simple.
Styling
A Card is commonly configured through chained style methods.
#![allow(unused)]
fn main() {
card()
.padding(16)
.radius(12)
.shadow(4)
.children([
text(localize("user.profile")),
text(localize("user.description")),
])
}
Styling remains part of the component configuration rather than requiring a separate stylesheet or styling abstraction.
Because these methods compose naturally, the same Card primitive can be used across an application’s design system:
#![allow(unused)]
fn main() {
card()
.padding(24)
.radius(16)
.children([
icon("database"),
text(localize("database.status")),
badge(localize("database.connected")),
])
}
The exact visual treatment can belong to the application’s design system while the underlying Card remains the same reusable primitive.
Card State
Cards can respond to state when the application needs state-specific presentation.
#![allow(unused)]
fn main() {
UiElement::card()
.hover(|style| style.scale(1.02))
.success_style(|style| style.border_width(2).border_color("#16a34a"))
.children([text("Remote resource")])
}
Hover styling reads pointer interaction on the Card or its descendants. A Card
is not focusable by default, so focused styles require the app to make it
focusable and request focus. To use app-owned status styling, attach a
FormStatusState to the Card entity:
#![allow(unused)]
fn main() {
let card = UiElement::card()
.success_style(|style| style.border_color("#16a34a"))
.body(text("Remote resource"))
.spawn_in_context(&mut context);
context.world.entity_mut(card).insert(FormStatusState {
status: FormStatus::Success,
});
}
Card style callbacks do not accept model predicates. Set status on the entity
from app code, or attach a SemanticNode/interaction component for its
corresponding interaction state. Card itself does not have a disabled policy.
Interactive Cards
A Card is not inherently interactive.
If an application wants a Card to respond to pointer or keyboard interaction, that interaction should be explicitly declared rather than being assumed from the component’s appearance.
#![allow(unused)]
fn main() {
card()
.label(localize("document.card.label"))
.aria(localize("document.card.description"))
.on("click", Command::Document::Open)
.children([
text(Document::title),
text(Document::summary),
])
}
The important part is that the Card still does not know what Command::Document::Open means. The Card produces a UI Event, and application wiring determines what happens next.
For a UI-specific interaction, the Card can expose an abstract UI Event:
#![allow(unused)]
fn main() {
card()
.label(localize("document.card.label"))
.aria(localize("document.card.description"))
.on("click_start", Ui::DocumentCard::Pressed)
.on("click_end", Ui::DocumentCard::Released)
.on("click", Ui::DocumentCard::Clicked)
}
The distinction is the same as with Button:
UI interaction
↓
Ui::DocumentCard::Clicked
↓
application wiring
↓
Command::Document::Open
The Card does not need to know that it represents a document. It only exposes the interface interaction.
Accessibility
A purely visual Card that only contains content does not automatically become an interactive control simply because it looks like one.
When a Card is interactive, its accessible identity and description should be explicit:
#![allow(unused)]
fn main() {
card()
.label(localize("document.card.label"))
.aria(localize("document.card.description"))
.on("click", Command::Document::Open)
.children([
text(Document::title),
text(Document::summary),
])
}
This is particularly important for Cards that visually resemble buttons, links, selectable records, or other controls.
The visual appearance should not be the only indication of what the Card does. Its semantics should be represented explicitly in the component configuration.
A visual container is not automatically an interactive control.
Data-Driven Cards
Cards become especially useful when their contents come from application data.
#![allow(unused)]
fn main() {
card()
.header(text(User::name))
.body([
text(User::email),
badge(User::status),
])
}
The Card does not own User. It receives a projection of application state and presents it.
This means the same Card structure can represent many records:
#![allow(unused)]
fn main() {
users.iter()
.map(|user| {
card()
.header(text(user.name()))
.body([
text(user.email()),
badge(user.status()),
])
})
}
The Model remains the source of truth. The View turns that state into Cards.
This separation is important for AI-generated applications because the component does not need to understand where its data came from. The same Card can present database records, API responses, local files, streams, or AI-generated data.
Conditional Content
Cards can also compose content based on application state.
#![allow(unused)]
fn main() {
card()
.children([
text(User::name),
if User::is_active {
badge(localize("user.active"))
} else {
badge(localize("user.inactive"))
},
])
}
The Card remains responsible only for presenting the resulting composition.
The decision about whether a user is active belongs to application state or domain logic, not to the Card itself.
Loading and Async State
A Card can present loading state without implementing the operation that is loading.
#![allow(unused)]
fn main() {
card()
.children([
text(localize("document.title")),
text(Document::summary),
])
.submitting_style(|style| style.opacity(0.5))
}
A richer presentation can replace or augment its contents:
#![allow(unused)]
fn main() {
card()
.children([
text(Document::title),
text(Document::summary),
])
.submitting(|| [
spinner(),
text(localize("document.loading")),
])
}
The Card does not perform the asynchronous operation. It presents the state produced by the application.
This follows the same principle used throughout Beverly:
The Model owns the state. The View presents the state. Events describe interaction. Controllers connect interaction to application behavior.
Error and Success Presentation
Cards can present application state such as errors or successful operations.
#![allow(unused)]
fn main() {
card()
.error(|errors| text(errors.join("\n")))
.success(|| [
icon("check"),
text(localize("document.saved")),
])
}
The messages should come from the application’s localization and state mechanisms rather than being hard-coded into the component.
The Card presents the meaning supplied by the application; it does not decide what constitutes an error or success.
Cards Within Cards
Because Cards are ordinary composable components, they can contain other Cards.
#![allow(unused)]
fn main() {
card()
.header(text(localize("dashboard")))
.body([
card()
.children([
text(localize("users")),
text(User::count),
]),
card()
.children([
text(localize("documents")),
text(Document::count),
]),
])
}
This is useful for dashboards and hierarchical interfaces, but it is not a special Card feature. It is simply the result of components being composable.
children() provides the common language.
Card Events
When interaction is required, Card Events should remain about the Card’s UI lifecycle.
#![allow(unused)]
fn main() {
card()
.label(localize("document.card.label"))
.aria(localize("document.card.description"))
.on("click_start", Ui::DocumentCard::Pressed)
.on("click_end", Ui::DocumentCard::Released)
.on("click", Ui::DocumentCard::Clicked)
}
These Events can be used independently of application Commands.
For example, a pressed Event could control a visual effect:
#![allow(unused)]
fn main() {
.on("click_start", Ui::DocumentCard::Pressed)
}
while the completed interaction can be connected to application behavior:
#![allow(unused)]
fn main() {
.on("click", Command::Document::Open)
}
This keeps the component’s vocabulary independent from the application’s domain vocabulary.
The Card reports interaction. The application decides what the interaction means.
The Card Contract
The Card has a deliberately small conceptual contract:
Create. Configure. Compose. Present. Optionally interact.
A Card provides:
- visual structure
- spacing and presentation
- composition through
children() - optional semantic sections such as
header,body, andfooter - state-specific presentation
- accessibility semantics when acting as an interactive control
- UI Events when interaction is required
It does not own:
- application data
- business rules
- domain validation
- application Commands
- database operations
- API operations
- agent behavior
Those concerns belong elsewhere in the architecture.
The result is a component that can be reused from a simple settings page to a high-throughput enterprise dashboard without changing what a Card fundamentally is.
The Card is a container, not an application.
Row
Rows arrange related content along a horizontal axis. They make the relationship between items explicit when those items belong together but should remain distinct: a label and value, a group of actions, or a set of compact controls.
When to use
Use a row when horizontal order carries meaning or helps users scan a group quickly. Rows work well for:
- headings paired with actions
- labels paired with status or metadata
- short groups of buttons, filters, or navigation items
- compact content that should share one visual line
Use a column when the content needs a vertical reading order. Use a stack when consistent spacing and linear flow matter more than the specific horizontal relationship between items. Use a grid when several items need repeated alignment across both rows and columns.
Composition model
A row should have one clear purpose and a small number of related children. Each child keeps its own semantic role, while the row provides the shared alignment and spacing that makes the group read as one unit.
Row
├── Label
├── Supporting content
└── Action
Prefer stable alignment over decorative variation. Keep the primary content easy to find, place actions consistently, and allow long content to wrap or yield space rather than pushing related controls out of view.
Example structure
#![allow(unused)]
fn main() {
use bevy::prelude::*;
use beverly::prelude::*;
fn build_status_row(mut commands: Commands) {
commands.spawn(NodeBundle::default()).with_children(|row| {
row.spawn(text("Sync status"));
row.spawn(Badge::new("Up to date"));
row.spawn(BeverlyButton::secondary("Details"));
});
}
}
The row is responsible for the relationship between these children. The Model still owns the status, and the View decides how that state should be presented. Keeping those responsibilities separate makes the row easy to reuse and change.
Guidance
- keep children related by meaning, not only by proximity
- use consistent alignment when several rows appear in the same view
- reserve the most stable space for the primary label or value
- avoid placing unrelated actions in the same row
- switch to a column when the content becomes difficult to scan horizontally
Column
Columns arrange related content along a vertical axis. They establish a reading order from top to bottom, making them the natural structure for forms, detail panels, settings sections, and other content that should unfold progressively.
When to use
Use a column when vertical order carries meaning or when each item needs room to be understood before the next one appears. Columns work well for:
- page and panel sections
- forms with labels, fields, and validation messages
- titles followed by supporting content
- detail views with metadata and actions
- stacked controls that should remain easy to scan
Use a row when related items belong on one horizontal line. Use a stack when the main concern is consistent spacing between a sequence of children. Use a grid when repeated content needs alignment across multiple columns.
Composition model
A column should follow the way a user reads or completes the content. Put the most important context first, keep supporting information close to the content it explains, and place actions where they naturally follow from the preceding information.
Column
├── Heading
├── Supporting content
├── Primary content
└── Actions
Each child keeps its own semantic role. The column provides the shared order, width, and spacing that lets those roles read as one coherent section.
Example structure
#![allow(unused)]
fn main() {
use bevy::prelude::*;
use beverly::prelude::*;
fn build_settings_column(mut commands: Commands) {
commands.spawn((
NodeBundle::default(),
BeverlyStack::vertical(),
)).with_children(|column| {
column.spawn(BeverlyTitle::new("Workspace settings"));
column.spawn(text("Manage the defaults for this workspace."));
column.spawn(BeverlyInput::new("Workspace name"));
column.spawn(BeverlyButton::primary("Save changes"));
});
}
}
The column shapes the View, but it does not own the settings or the save operation. The Model remains the source of truth, and an event or controller action can handle the user’s intent when the button is pressed.
Guidance
- order children according to the user’s reading or completion flow
- keep related labels, content, and actions close together
- use consistent widths when several columns appear in the same view
- give dense sections enough vertical spacing to preserve hierarchy
- move to a row only when items are genuinely related on the same horizontal line
Stack
Stacks sequence related content with consistent spacing. They are the simplest way to create a predictable flow when the order of children matters more than a specialized alignment relationship.
A stack can run vertically or horizontally. The direction determines the reading flow; the spacing gives each child enough separation to remain distinct without breaking the group into unrelated pieces.
When to use
Use a stack when the interface should feel linear and easy to follow. Stacks work well for:
- forms and settings sections
- headings followed by supporting content
- lists of related items
- action groups and compact controls
- cards or panels with a consistent internal rhythm
Use a row when the horizontal relationship between specific children is the important part. Use a column when you need to describe a vertical hierarchy. Use a grid when repeated items need alignment across both axes.
Composition model
A stack should contain children that belong to the same sequence. It provides the rhythm between those children, while each child remains responsible for its own meaning and interaction.
Stack
├── First item
├── Second item
└── Third item
Choose vertical flow for reading, completion, and narrative order. Choose horizontal flow for short groups of controls or values that users should scan as one line. When a horizontal group becomes too long or wraps unpredictably, move it into a vertical stack or a more structured layout.
Example structure
#![allow(unused)]
fn main() {
use bevy::prelude::*;
use beverly::prelude::*;
fn build_project_setup(mut commands: Commands) {
commands.spawn((
NodeBundle::default(),
BeverlyStack::vertical(),
)).with_children(|stack| {
stack.spawn(BeverlyTitle::new("Project setup"));
stack.spawn(BeverlyInput::new("Workspace name"));
stack.spawn(BeverlyButton::primary("Create workspace"));
});
}
}
The stack controls the order and spacing of the setup flow. It does not own the workspace value or the create operation. The Model owns the data, while the View presents the form and emits the user’s intent through the application’s event and controller flow.
Guidance
- keep all children in a stack related by sequence or purpose
- use one spacing rhythm for items at the same level
- prefer vertical stacks for content that must be read or completed in order
- keep horizontal stacks short enough to scan without wrapping
- use nested stacks sparingly and give each one a clear responsibility
Content & Children
Beverly components are designed to be composed through the shared UiElement
tree. text(...), button(), input(...), checkbox(), alert(...),
toast(...), card(), list_item(...), form(...), radio(...), table(...),
tabs(...), modal(...), and custom component adapters can all be placed in
the same children collection.
The fundamental primitive for composition is:
#![allow(unused)]
fn main() {
.children(...)
}
A component can accept one child or many children.
#![allow(unused)]
fn main() {
app()
.children([text("Hello")])
.run()
}
Or:
#![allow(unused)]
fn main() {
app()
.children([
text("Hello"),
text("World"),
])
.run()
}
This simple rule provides a consistent way to build interfaces from small pieces.
Everything can be a child. Everything can be composed.
One Child or Many
children() accepts either a single value or a collection of values.
A single child is useful when the relationship is straightforward:
#![allow(unused)]
fn main() {
button()
.text("Save")
.children([ButtonChild::icon("save"), ButtonChild::text("Save")])
}
Multiple children allow a component to become a container:
#![allow(unused)]
fn main() {
button()
.children([
ButtonChild::icon("folder"),
ButtonChild::text("Open project"),
])
}
The API stays the same regardless of how many children a component contains.
There is no need to learn separate APIs for one child, two children, or a list of children.
Composition Over Special Cases
Beverly components are designed to be building blocks rather than complete screens.
A component can contain another component.
That component can contain more components.
Application
↓
Page
↓
Card
↓
Header
↓
Icon + Text + Button
The same composition model works at every level.
#![allow(unused)]
fn main() {
app()
.children([
text("Report").children([text("Quarterly financial report")]),
button().text("Open"),
input("Search").label("Search reports"),
checkbox().label("Include archived").checked(true),
card().children([text("Summary")]),
list_item("Recent activity").subtitle("Just now"),
form(Form::post("/save")).children([input("Name")]),
table(TableConfig::default()),
])
.run()
}
This makes components reusable without requiring the framework to predict every possible combination developers might need.
Build the primitive once. Compose it everywhere.
Named Sections
Card, Container (column() and row()), Form, Modal, Alert, Toast, Tabs, and
Sidebar implement .header(...), .body(...), and .footer(...) as real
composition sections. They map to top, middle, and bottom, regardless of
builder-call order. Each accepts one UiElement or an iterable; repeated calls
append to the selected section. In a sized panel, the body grows into the
available height so the footer stays below it.
For Card, Container, Form, and Tabs, ordinary .children(...) content becomes
part of the body when sections are used. Tabs apply sections within each tab
zone; Sidebar keeps its navigation controls in the body. Modal retains its
dialog title and close control. Alert and Toast retain their built-in message,
icon, and dismissal behavior alongside custom content.
Navbar and the standalone Footer use horizontal left/center/right regions instead. Table headers describe columns, not a generic panel header. Buttons, fields, typography, and other leaf components do not have vertical sections; compose them inside a sectioned container instead.
Some components have a meaningful internal structure.
A Card, for example, may have:
- Header
- Body
- Footer
For these components, Beverly can provide named sections alongside general children() composition.
#![allow(unused)]
fn main() {
card().children([
text("Account"),
text("Account information"),
button().text("Save"),
])
}
Named sections make the intent of the interface immediately obvious.
They also provide components with a way to express their own semantic structure without requiring developers to manually construct the underlying layout.
Sections Can Still Be Composed
Named sections follow the same content rules.
A section can receive a single value:
#![allow(unused)]
fn main() {
card()
.header(
text("Settings")
)
}
Or multiple values:
#![allow(unused)]
fn main() {
card()
.header([
icon("settings"),
text("Settings"),
button().text("Edit"),
])
}
The section itself becomes a composition boundary.
Card
├── Header
│ ├── Icon
│ ├── Text
│ └── Button
│
├── Body
│ └── Content
│
└── Footer
└── Actions
This allows a component to provide useful semantic structure without sacrificing the flexibility of general composition.
children() Is the Universal Escape Hatch
Named sections are useful when a component has a well-defined structure.
But developers should never be trapped by them.
children() remains the general composition primitive.
For a simple container:
#![allow(unused)]
fn main() {
container()
.children([
text("First"),
text("Second"),
text("Third"),
])
}
For a more structured component:
#![allow(unused)]
fn main() {
card()
.header(text("Title"))
.children([
chart(data),
table(rows),
])
.footer(button().text("Close"))
}
The component can provide convenient named sections while still allowing arbitrary content where appropriate.
This keeps the API expressive without creating a separate abstraction for every possible layout.
Content Is Data
Beverly treats component content as values that can be passed, returned, stored, and composed.
That means a component does not need to know where its content came from.
Content might be:
- Static text
- Another component
- A list of components
- Data-driven content
- Conditional content
- Application state
- AI-generated content
The component simply receives content and presents it according to its role.
#![allow(unused)]
fn main() {
card()
.header(text(user.name()))
.children([
text(user.email()),
badge(user.status()),
])
}
The View decides what should be presented.
The component decides how that content is presented.
The Model remains responsible for the underlying application data.
Consistent Composition
The same mental model should work throughout a Beverly application.
#![allow(unused)]
fn main() {
container()
.children([
navbar(),
card()
.header(text("Users"))
.children([
user_table(),
])
.footer([
button().text("Previous"),
button().text("Next"),
]),
])
}
Developers don’t need to memorize a different composition system for every component.
They learn one fundamental concept:
Component
↓
children()
↓
Child or Children
Named sections simply add semantic structure where it is useful.
Why This Matters for AI
A consistent composition model is particularly valuable when code is generated by LLMs.
The model does not need to invent a new layout abstraction for every component.
It learns a small vocabulary:
component()
children(...)
header(...)
body(...)
footer(...)
That makes generated code easier to predict and easier for humans to review.
A developer looking at:
#![allow(unused)]
fn main() {
card()
.header(...)
.children(...)
.footer(...)
}
can immediately understand the structure.
The code itself becomes a description of the interface.
Composition should be obvious from the code.
The Beverly Composition Rule
Beverly keeps content composition deliberately simple:
Use children() for general composition.
Use named sections when a component has meaningful semantic regions.
Accept one child or many children.
Keep components composable.
Don’t create a new abstraction when ordinary composition already solves the problem.
The result is a UI system where small primitives can become complex interfaces without the API becoming complex.
Small primitives. Simple composition. Unlimited combinations.
Layout
Layout components control how content occupies space inside a Beverly view. They define alignment, direction, spacing, sizing, and adaptation so that a composition remains readable as its content and viewport change.
Layout is a presentation responsibility. It should make relationships visible and predictable without taking ownership of application state or business rules.
Layout model
Use the smallest primitive that expresses the relationship you need:
- alignment positions children within their available space
- flex direction determines whether content flows by row or column
- gap creates consistent space between siblings
- margin controls space outside a component’s own boundary
- padding controls space between a boundary and its content
- min and max constraints keep dimensions within useful limits
- width and height define the intended size of a region
- responsive rules let the composition adapt when available space changes
Layout
├── Boundary
│ ├── Width / Height
│ └── Min / Max
├── Flow
│ └── Flex direction
├── Rhythm
│ ├── Gap
│ ├── Margin
│ └── Padding
└── Placement
└── Alignment
Composition guidance
Start with structure, then add spacing, then tune sizing. A stable layout usually needs only a few deliberate decisions: which direction content flows, how children align, how much space separates them, and what should happen when the available space is smaller or larger than expected.
Prefer shared layout rules over per-child offsets. This keeps a screen easier to scan and prevents one component from quietly compensating for another component’s incorrect sizing.
Example
#![allow(unused)]
fn main() {
use bevy::prelude::*;
fn build_panel(mut commands: Commands) {
commands.spawn((
Node {
display: Display::Flex,
flex_direction: FlexDirection::Column,
align_items: AlignItems::Stretch,
row_gap: Val::Px(12.0),
padding: UiRect::all(Val::Px(16.0)),
width: Val::Percent(100.0),
..default()
},
));
}
}
The example establishes a vertical flow, a shared rhythm, an inset boundary, and a width that can participate in its parent’s layout. The content components inside the panel can then focus on their own meaning.
Alignment
Alignment determines how children are positioned inside the space made available by their parent. It is the layout decision that turns a group of elements into a readable relationship: centered actions, leading labels, or controls aligned to a common edge.
When to use
Use alignment to establish a shared visual edge or axis. Common patterns include:
- leading-aligning labels and body content
- centering a compact action or status group
- stretching fields to the width of a form
- aligning toolbar items along their cross axis
Alignment should describe the relationship of a group, not compensate for inconsistent child sizes.
Main and cross axes
Flex direction determines the main axis. In a row, children flow horizontally; in a column, they flow vertically. Alignment then controls placement along the main axis and the cross axis.
Column flow
main axis: top -> bottom
cross axis: left <-> right
Row flow
main axis: left -> right
cross axis: top <-> bottom
Choose alignment based on the content’s reading path. A form usually stretches fields across its cross axis, while a compact toolbar often centers controls vertically.
Example
#![allow(unused)]
fn main() {
Node {
display: Display::Flex,
flex_direction: FlexDirection::Column,
align_items: AlignItems::Stretch,
..default()
}
}
Guidance
- align related children to a shared edge
- use center alignment for compact groups, not long reading content
- let the parent establish alignment instead of adding offsets to each child
- verify alignment at narrow and wide viewport sizes
Flex
Flex layout distributes children along a direction and lets the available space determine how that group behaves. It is the foundation for rows, columns, toolbars, forms, and other compositions whose children have a meaningful order.
When to use
Use flex layout when content should flow as a group rather than occupy unrelated absolute positions. It works well for:
- navigation and action bars
- form sections
- split content regions
- reusable component shells
- layouts that need to grow with their children
Direction and growth
Set the direction first. A row creates a horizontal flow; a column creates a vertical flow. Then decide which children may grow, shrink, or keep their intrinsic size.
Flex container
├── Child A -> fixed or intrinsic size
├── Child B -> may grow into remaining space
└── Child C -> fixed or intrinsic size
Keep growth intentional. A flexible content region can absorb remaining space, while labels and actions should usually retain enough room to stay recognizable.
Example
#![allow(unused)]
fn main() {
Node {
display: Display::Flex,
flex_direction: FlexDirection::Row,
align_items: AlignItems::Center,
column_gap: Val::Px(8.0),
..default()
}
}
Guidance
- use flex for relationships that should survive content changes
- define direction before tuning spacing or alignment
- allow the content area to grow only when that behavior is useful
- avoid deeply nested flex containers when one parent rule is enough
- use a grid when both axes need repeated alignment
Gap
Gap creates consistent space between siblings in a flex or grid layout. It expresses rhythm at the parent level, which keeps related children evenly separated without requiring each child to know about its neighbors.
When to use
Use gap for repeated spacing between items that share a layout context:
- fields in a form
- actions in a toolbar
- cards in a grid
- sections in a vertical content flow
Use margin when the space belongs to one component’s relationship with something outside its own group. Use padding when the space belongs inside a component boundary.
Row and column gaps
For a horizontal flow, column gap separates neighboring items. For a vertical flow, row gap separates items from top to bottom. In a grid, both directions may need independent values.
Parent group
├── Child A
│ < gap >
├── Child B
│ < gap >
└── Child C
Example
#![allow(unused)]
fn main() {
Node {
display: Display::Flex,
flex_direction: FlexDirection::Column,
row_gap: Val::Px(12.0),
..default()
}
}
Guidance
- choose a shared spacing token or value for siblings at the same level
- use larger gaps to separate independent sections
- use smaller gaps for tightly related labels, values, or controls
- avoid mixing child margins and parent gaps unless the distinction is deliberate
Margin
Margin creates space outside a component’s own boundary. It describes how one layout item relates to surrounding content, making it useful for separating sections or creating a deliberate offset within a larger composition.
When to use
Use margin when the space belongs to the component’s relationship with its parent or neighboring regions:
- separating a heading from the section above it
- creating page-level or panel-level breathing room
- placing a component at a deliberate outer edge
- reserving space around a standalone surface
Use gap for consistent spacing between siblings in one group. Use padding for space inside the component’s boundary.
parent boundary
< margin > [ component boundary ] < margin >
Example
#![allow(unused)]
fn main() {
Node {
margin: UiRect {
top: Val::Px(24.0),
right: Val::Px(0.0),
bottom: Val::Px(0.0),
left: Val::Px(0.0),
},
..default()
}
}
Guidance
- use margin to define a component’s outer relationship
- prefer parent-level gap when all siblings share the same rhythm
- avoid using arbitrary margins to repair a broken alignment rule
- keep page-level margins consistent across related views
Min and Max
Minimum and maximum constraints keep a layout usable across changing content and viewport sizes. They prevent a region from collapsing below a readable size or expanding until its content becomes difficult to scan.
When to use
Use constraints when a component has a useful range rather than one fixed dimension:
- readable content columns
- sidebars and navigation regions
- cards that should not become excessively wide
- controls that need room for their labels
- panels that must remain usable on narrow screens
Constraint model
Think of the final size as a bounded range:
minimum size <= resolved size <= maximum size
The available space and the component’s content determine the resolved size inside that range. Constraints should protect readability, not replace responsive composition.
Example
#![allow(unused)]
fn main() {
Node {
width: Val::Percent(100.0),
min_width: Val::Px(240.0),
max_width: Val::Px(720.0),
..default()
}
}
Guidance
- set a minimum only when the content truly needs protected space
- use maximum widths to preserve readable line lengths
- test constraints with both short and long content
- avoid incompatible constraints that leave no useful resolved size
- combine min and max values with responsive flow rather than fixed offsets
Padding
Padding creates space inside a component’s boundary, between that boundary and its children. It gives content room to breathe and makes the extent of a surface clear without changing the relationships between sibling components.
When to use
Use padding for:
- card and panel insets
- control touch and interaction areas
- separating text from a surface edge
- creating a stable content region inside a container
Use gap to separate siblings. Use margin to separate the component from content outside its boundary.
[ component boundary ]
< padding > content < padding >
[ component boundary ]
Example
#![allow(unused)]
fn main() {
Node {
padding: UiRect::axes(
Val::Px(20.0),
Val::Px(16.0),
),
..default()
}
}
Guidance
- keep padding consistent across surfaces that share a visual role
- use larger insets for major panels and smaller insets for compact controls
- make sure padding does not reduce the usable space of dense content too far
- treat padding as part of the component’s boundary, not as a child-specific fix
Responsive Layout
Responsive layout adapts composition when the available space changes. It is not only about shrinking dimensions; it is about preserving hierarchy, readability, and usable interaction across viewport sizes.
When to use
Use responsive rules when a composition may appear in different windows, panels, or device sizes:
- dashboards that move from multi-column to single-column content
- toolbars whose actions need a narrower arrangement
- forms that change from side-by-side fields to a vertical flow
- panels whose width should remain readable instead of filling all space
Adapt the structure first
When space becomes constrained, change the relationship between components before reducing their legibility. A row may become a column, a grid may reduce its column count, or secondary content may move below the primary content.
Wide space: [ navigation ] [ main content ] [ details ]
Narrow space: [ navigation ]
[ main content ]
[ details ]
Use width, height, and min/max constraints to provide stable bounds. Use flex direction, wrapping, and alignment to express how the composition should respond inside those bounds.
Example
#![allow(unused)]
fn main() {
Node {
width: Val::Percent(100.0),
max_width: Val::Px(960.0),
flex_direction: FlexDirection::Column,
..default()
}
}
The example keeps the region fluid while protecting its maximum reading width. The surrounding composition can decide when related regions should stack or change priority.
Guidance
- test layouts at the smallest and largest useful sizes
- preserve action visibility and readable text before visual symmetry
- use progressive disclosure for secondary content when space is limited
- avoid relying on one fixed viewport size as the design target
- keep responsive decisions at the parent layout boundary when possible
Width and Height
Width and height define the intended dimensions of a layout region. They can be fixed, relative to the parent, or left for the layout system and content to resolve automatically.
When to use
Use explicit dimensions when a region has a stable format or interaction requirement:
- icon and media surfaces with a known aspect
- navigation bars and tool regions
- full-width fields and panels
- modal or card bounds
Use relative values when a region should participate in its parent’s available space. Use intrinsic or automatic sizing when content should determine the dimension.
Dimension choices
Fixed -> stable pixel or logical size
Percentage -> proportional to the parent
Auto -> resolved from content and layout rules
Min / Max -> bounded flexible size
Prefer the least rigid choice that preserves usability. A fixed height can stabilize a toolbar, while a content region usually benefits from a flexible width with a readable maximum.
Example
#![allow(unused)]
fn main() {
Node {
width: Val::Percent(100.0),
height: Val::Auto,
min_height: Val::Px(120.0),
max_width: Val::Px(720.0),
..default()
}
}
Guidance
- use stable dimensions for fixed-format controls and media
- let text-heavy regions grow when content can vary
- pair fluid width with max width to preserve readable line length
- avoid fixed heights around content that may wrap or localize
- check dimensions with empty, short, and long content
Typography
Typography gives Beverly interfaces a readable hierarchy. It defines how titles, body copy, labels, captions, and other text roles differ while keeping their font, scale, and color connected to the active theme.
The typography system has three responsibilities:
- establish hierarchy so users can tell what matters first
- keep text readable across cards, panels, forms, and app shells
- provide theme-driven defaults with deliberate per-instance overrides when needed
Typography
├── Titles
│ └── Mark the hierarchy of screens and sections
├── Text roles
│ └── Describe body, label, caption, and status content
├── Dots
│ └── Animate trailing periods to signal ongoing work
└── Theme
└── Supplies shared family, size, and color defaults
Choosing a text primitive
Use a title when the text names or introduces a screen, panel, card, or section. Use a text role when the content explains, labels, annotates, or reports status within that structure.
Choose a semantic role before reaching for a manual size or color. Roles preserve a consistent hierarchy when the theme changes, while one-off overrides should be reserved for content that genuinely needs different emphasis.
Theme relationship
Titles and themed text read their defaults from the active theme. This keeps a font or scale change global and predictable: updating the theme can restyle an entire application without editing every component that contains text.
The View chooses the semantic primitive and content. The theme supplies the visual defaults. Application state and business rules remain outside the typography layer.
Example
#![allow(unused)]
fn main() {
use bevy::prelude::*;
use beverly::prelude::*;
fn build_header(mut commands: Commands) {
commands.spawn((
text("Workspace"),
ThemedTitle::new(TitleLevel::H1),
));
commands.spawn((
text("Last synced 2 minutes ago"),
ThemedText::new(TextRole::Caption),
));
}
}
Guidance
- use titles to establish hierarchy, not to style ordinary body copy
- choose the closest semantic text role before overriding size or color
- keep titles short enough to remain scannable at narrow widths
- let the active theme control shared typography defaults
- verify contrast and legibility whenever applying a manual color override
Text
Live Demo
Open the demo in a separate tab.
Use text("...") as the canonical way to create ordinary text in a Beverly
application:
#![allow(unused)]
fn main() {
ui().children([text("Hello, Beverly!")])
}
The helper returns Bevy’s Text component. Beverly’s typography systems then
apply the embedded default font and synchronize its size, color, shaping, and
layout. Use ThemedText when the text needs a semantic role such as a heading,
caption, or muted label. Add ordinary text to an application with
ui().children([...]); the root Ui composition owns the application tree. Every
composable Beverly element follows the same .children([...]) convention.
Text carries the supporting content around a title: explanations, labels, metadata, helper copy, and status details. It is the flexible, lower-emphasis counterpart to the title primitive and should inherit the active theme’s typography rules.
When to use
Use themed text when content belongs inside an existing hierarchy rather than naming the hierarchy itself. Common uses include:
- descriptions below a title
- labels attached to controls
- captions and timestamps
- helper and validation messages
- muted metadata or status details
Use ThemedTitle when the text introduces a meaningful screen, panel, card, or section.
Text roles
TextRole gives common content a semantic size and color:
Heading- title-sized supporting headingBody- ordinary readable copyLabel- compact text for controls and field labelsCaption- small muted supporting informationMuted- body-sized secondary copyAccent- compact text using the theme’s primary colorDisabled- text for unavailable controls or content
The active theme resolves the exact size and color for each role. Prefer a role change over a manual override so the text remains coherent across themes.
Example
#![allow(unused)]
fn main() {
use bevy::prelude::*;
use beverly::prelude::*;
fn my_ui() -> Ui {
ui().children([text("Last synced 2 minutes ago")])
}
}
Overrides
When a single piece of text genuinely needs a different treatment, use .size(...) or .color(...) on ThemedText. These overrides are local decisions and should not replace the shared role system:
#![allow(unused)]
fn main() {
app()
.children([text("Sync failed")])
.run();
}
Manual colors bypass the theme’s default contrast choices, so verify them in every theme where the text appears. Font family is controlled through the Typography component or the application’s global typography setup.
Guidance
- use the smallest role that preserves comfortable reading
- keep labels and metadata concise
- use muted roles for secondary information, not essential instructions
- let the surrounding layout control text spacing and alignment
- allow body text to wrap rather than clipping or forcing a fixed height
Title
Live Demo
Open the demo in a separate tab.
Titles identify the screen, panel, card, or section a user is viewing. They are the highest-emphasis content primitive in a local hierarchy and should make the purpose of a surface clear at a glance.
When to use
Use a title when text names a meaningful region or introduces the content that follows. Titles work well for:
- page and screen headings
- panel and card headers
- section labels in long forms or settings views
- modal headings that establish the task
Use ThemedText for supporting copy, metadata, labels, and status. A title should not be used only because text needs to be larger; its semantic role should justify the emphasis.
Title levels
TitleLevel provides a predictable hierarchy from the largest display treatment to the smallest heading:
Display- prominent app or feature-level headingH1- primary screen headingH2- major panel or subsection headingH3- standard section headingH4- supporting section headingH5- compact heading with muted emphasisH6- smallest heading, used sparingly
The active theme resolves the actual size and color for each level. Higher levels use stronger foreground emphasis, while H4 through H6 are progressively quieter for nested structure.
Example
#![allow(unused)]
fn main() {
use bevy::prelude::*;
use beverly::prelude::*;
fn build_panel_header(mut commands: Commands) {
commands.spawn((
text("Project settings"),
ThemedTitle::new(TitleLevel::H2),
));
}
}
Changing size or color
Prefer changing TitleLevel when the title’s place in the hierarchy changes. When one title genuinely needs a one-off treatment, use .size(...) or .color(...) on ThemedTitle:
#![allow(unused)]
fn main() {
commands.spawn((
text("Ready to publish"),
ThemedTitle::new(TitleLevel::H3)
.size(24.0)
.color(Color::srgb(0.12, 0.45, 0.32)),
));
}
Manual overrides bypass the corresponding theme defaults. Check contrast and verify the result in every theme where the title appears.
Composition guidance
- keep one clear primary title for each major surface
- place the title before the content it introduces
- use supporting text for explanations instead of making titles paragraph-length
- preserve a consistent level relationship between neighboring sections
- allow long titles to wrap rather than forcing a fixed height that can clip text
Titles inherit the same typography pipeline as other text entities, so font family changes made through Typography or the global font manager can apply across the application without rewriting each title.
Dots
Live Demo
Open the demo in a separate tab.
Dots is an animated run of trailing periods that signals ongoing work. It
cycles through no dots, ., .., and ..., then returns to no dots,
repeating for as long as the entity exists.
When to use
Use Dots after a short status phrase when something is loading, thinking, or waiting and the result has no measurable progress:
- “Thinking” while a model prepares a response
- “Loading” while content is fetched
- “Waiting for connection” in a status line
Use a progress bar when the amount of work done is known. Use a spinner when the indicator should stand alone without text.
Example
#![allow(unused)]
fn main() {
use bevy::prelude::*;
use beverly::prelude::*;
fn build_status(mut commands: Commands) {
commands
.spawn(Node {
flex_direction: FlexDirection::Row,
..default()
})
.with_children(|row| {
row.spawn((text("Thinking"), ThemedText::new(TextRole::Muted)));
row.spawn((Dots::new(), ThemedText::new(TextRole::Muted)));
});
}
}
Dots owns the entity’s Text, so do not set the text yourself. Pair it with
ThemedText for color and size so it matches the surrounding copy.
Options
Dots::new()- three dots, 0.4 seconds per step.interval(seconds)- how long each step, including the empty one, is shown.max_dots(count)- the most dots shown before the cycle returns to none
Guidance
- place Dots in its own text entity beside the label so the label does not re-wrap or shift as the dots change
- remove the entity when the work finishes; Dots does not stop on its own
- keep the interval slow enough to read; around 0.3 to 0.5 seconds works well
- do not rely on the animation alone to convey status, as the label carries the meaning
Forms
Supported form controls bind to Bevy resource models through .bind(...).
User edits call the generated or custom setter; successful values are read back
so normalization reaches the control. Model-side changes synchronize to controls
on the next UI update.
Binding Controls
Configure the concrete builder first, then call .bind(...). Binding returns a
UiElement that can be composed in a Form, Container, or another section.
#![allow(unused)]
fn main() {
use beverly::prelude::*;
use beverly::components::input::TextInputConfig;
#[model]
struct User {
email: String,
active: bool,
priority: u32,
}
let user = User {
email: String::new(),
active: false,
priority: 1,
};
let fields = [
TextInputConfig::new("Email").label("Email address").bind(User::email),
CheckboxConfig::new().label("Active").bind(User::active),
Select::new(vec![1u32, 2, 3]).bind(User::priority),
];
}
Insert the model as a Bevy resource. BeverlyPlugin installs BindingPlugin;
a custom app must install it explicitly. Configure concrete controls before
binding because .bind(...) consumes the builder and returns UiElement.
| Control builder | Bound value |
|---|---|
TextInputConfig | String, numeric scalar, or bool represented as text |
TextareaConfig | String |
CheckboxConfig, ToggleConfig | bool |
RadioGroupBuilder | String option value |
Select<T> | T: BindingValueType + Display |
Slider | Numeric scalar within the slider range |
DropdownConfig | String option value, not its display label |
FileInput | Vec<SelectedFile> |
Search / SearchBox | String |
BindingValueType has built-in conversions for String, bool, numeric scalar
values, and Vec<SelectedFile>. Custom value types can implement the trait.
Typed Select<T> additionally requires Display for accessible option labels.
The selected model value must match one of the Select’s options; an unmatched
value becomes a BindingErrorState instead of silently selecting another
option. Register SelectPlugin<T> for every concrete option type used.
For custom field accessors, create FieldBinding::new(getter, setter). The
getter returns the model value; the setter accepts &mut Model and returns
Result<(), BindingErrors>. A setter alone cannot support two-way binding
because the binding also needs a getter.
Pending and Success
Use .submitting(|| ...) and .success(|| ...) to add state-specific content
below a value control, Form, or submit control. Callbacks take no arguments and
return one UiElement or an iterable. They do not inspect models, messages, or
requests automatically.
#![allow(unused)]
fn main() {
let form = UiElement::form(Form::new())
.submitting(|| [spinner(), text("Saving changes…")])
.success(|| text("Saved"))
.spawn_in_context(&mut context);
context.world.get_mut::<FormStatusState>(form).unwrap().submitting();
// After the app's operation completes:
context.world.get_mut::<FormStatusState>(form).unwrap().success();
context.world.get_mut::<FormStatusState>(form).unwrap().idle();
}
The app owns the lifecycle. Callback content replaces on status changes and is
removed on Idle; it has polite live-region semantics. Controls are not
automatically disabled while submitting. Configure that policy separately.
See Form for sections and complete composition.
Error Callbacks
Value controls, typed Select<T>, Form, and submit controls expose
.error(|errors| ...). The callback receives the current &[String] and
returns composable content. For bound fields, setter/conversion failures update
BindingErrorState automatically. Form-level and unbound-control errors are
app-owned and must be set on that entity.
#![allow(unused)]
fn main() {
let form = Form::new()
.error(|errors| text(errors.join("\n")))
.spawn_in_context(&mut context);
context.world.get_mut::<BindingErrorState>(form).unwrap()
.error(BindingErrors::new("Unable to save. Please retry."));
}
Changed nonempty errors replace the presentation; recovery removes it. Field errors are not automatically aggregated into Form-level errors. Invalid state, error announcements, and relationships are projected into accessibility semantics; verify delivery with the platforms and screen readers you support.
Submission
A generated Form submit control gathers named descendant values and emits
FormSubmitted. The app decides how to perform a request and handle its result.
It does not send network traffic automatically. See Submit for
GET, POST, PUT, PATCH, body encodings, and the optional blocking transport
feature.
Form
Form composes value-bearing controls, optional section content, and a generated
submit button. It does not own model state or perform HTTP requests. Bind each
control to the model and handle the emitted FormSubmitted message in the app.
See Submit for GET, POST, PUT, and PATCH requests.
Sections
UiElement::form(...) supports .header(...), .body(...), and .footer(...)
regions. Each accepts one element or an iterable; repeated calls append. The
regions render in header, body, footer order regardless of builder-call order.
Ordinary .children(...) content joins the body. The body expands to use the
available form height, and the generated submit button is placed in the footer.
#![allow(unused)]
fn main() {
UiElement::form(Form::post("/api/users"))
.header(text("Create account"))
.body([
UiElement::input("Name")
.name("name")
.bind(User::name),
UiElement::input("Email")
.name("email")
.bind(User::email),
])
.footer(text("Your information is used to create the account."))
}
The generated submit button is enabled by default. To provide a custom button,
set Form::show_submit_button(false) and place a button carrying the
FormSubmitButton marker from beverly::components::form in the Form subtree.
Binding and Validation
Bind each value-bearing control using its generated field handle. Configure the
control before calling .bind(...); the bound value then syncs in both
directions between the model and control. Setter normalization is reflected
back into the control, while rejected edits remain visible for correction and
expose BindingErrorState on the field. Supported builders and conversions
are listed in Binding Controls.
#![allow(unused)]
fn main() {
UiElement::input("Email")
.label("Email address")
.aria("Enter the email address associated with your account.")
.bind(User::email)
.error(|errors| text(errors.join("\n")))
}
Field errors are not automatically aggregated into the Form’s global error
state. Use a field’s .error(...) callback for setter errors and set
BindingErrorState on the Form entity for app-owned submission failures. See
Error Callbacks.
Pending and Success
The application owns the request lifecycle and updates FormStatusState on the
spawned Form. Callbacks add supplementary content; they do not inspect messages,
perform requests, restyle the Form, or disable controls automatically.
#![allow(unused)]
fn main() {
let form = UiElement::form(Form::post("/api/users"))
.submitting(|| [spinner(), text("Creating account…")])
.success(|| text("Account created"))
.spawn_in_context(&mut context);
context.world.get_mut::<FormStatusState>(form).unwrap().submitting();
// After the app's operation completes:
context.world.get_mut::<FormStatusState>(form).unwrap().success();
}
Call .idle() when state-specific content should be removed. Use
Form::disabled(true) separately if the entire form should reject interaction
while the request is pending. The callback content is exposed as a polite live
region; validate announcement timing with supported screen readers.
Styling
Forms use the shared Styling DSL:
#![allow(unused)]
fn main() {
UiElement::form(Form::new())
.padding(24)
.gap(16)
.width(480)
.background("#ffffff")
.border_width(1)
.border_color("#d4d4d4")
}
Explicit style properties are reapplied after component/theme visual updates.
State-specific paint uses .submitting_style(...) and .success_style(...);
state content uses .submitting(...) and .success(...).
Accessibility
Provide a visible or programmatic label for each value-bearing control. Use
.aria(...) when additional descriptive context is needed:
#![allow(unused)]
fn main() {
UiElement::input("Email")
.label("Email address")
.aria("Enter the address associated with your account.")
.bind(User::email)
}
Beverly projects labels, invalid state, descriptions, and live regions into accessible semantics at runtime. Check interaction and announcements with the screen readers and platforms your application supports.
Reset
Reset behavior is application-owned. Update or replace the model resource with the desired values; bound controls reflect those model changes on the next UI update. The Form does not provide an implicit reset event.
Submit
Bind value-bearing controls to the model; submission is a separate action.
BeverlyPlugin includes FormPlugin, which watches the generated submit
button and emits FormSubmitted with the owning form entity and a FormRequest.
The app owns network transport and response handling.
Methods and Encodings
Use the constructor matching the requested HTTP verb:
| Constructor | Request method | Fields |
|---|---|---|
Form::get(url) | GET | URL query string |
Form::post(url) | POST | Request body |
Form::put(url) | PUT | Request body |
Form::patch(url) | PATCH | Request body |
GET fields are URL-encoded and appended to existing query parameters before any
fragment. POST, PUT, and PATCH use FormEncoding::UrlEncoded by default;
.encoding(FormEncoding::Json) sends a JSON object instead. A field name can
occur more than once in URL-encoded data.
#![allow(unused)]
fn main() {
let create = Form::post("https://example.test/items")
.encoding(FormEncoding::Json)
.field("title", "Release notes")
.build_request();
let update = Form::put("https://example.test/items/42")
.field("title", "Revised release notes")
.build_request();
let partial_update = Form::patch("https://example.test/items/42")
.field("published", "true")
.build_request();
}
Form::field(...) and Form::fields(...) add fixed string values. Named
supported descendant controls add their current values. Disabled controls and
unchecked checkbox/toggle controls are omitted. The string-valued request API
does not upload files.
Handling Submission
The generated submit control gathers values and emits a message; it does not perform a request:
#![allow(unused)]
fn main() {
use bevy::prelude::*;
use beverly::prelude::*;
fn search_form() -> UiElement {
UiElement::form(Form::get("https://example.test/search"))
.children([
UiElement::input("Search terms")
.label("Search terms")
.name("q"),
])
}
fn handle_form_submit(mut submitted: MessageReader<FormSubmitted>) {
for message in submitted.read() {
let request = &message.request;
info!(form = ?message.form, method = ?request.method, url = %request.request_url());
}
}
}
For POST/PUT/PATCH, request_url() returns the action URL; values remain in
request.fields for the selected body encoding. The default Bevy update path
never performs blocking network I/O. With the optional http_form Cargo
feature, FormRequest::send() and Form::submit() are blocking reqwest
conveniences; call them from an app-owned worker/task, not from a Bevy system.
A custom submit button must carry the FormSubmitButton marker from
beverly::components::form; the default generated button already has it.
Nested forms are not included in an outer form’s field collection.
Pending and Errors
The application controls request state. Add .submitting(|| ...) and
.success(|| ...) callbacks to the form or submit control, then set
FormStatusState after beginning and completing the operation. These callbacks
only render supplementary content; they do not observe requests or disable
controls automatically. Use .disabled(true) when that is the intended policy.
Global request failures are also app-owned. Set BindingErrorState on the Form
entity to render its .error(...) callback. Field errors are not aggregated
into the Form automatically. See Form State and
Error Callbacks.
Text Input
Live Demo
Open the demo in a separate tab.
Model binding:
TextInputConfig::new("Email").label("Email").bind(User::email)connects the input to a generated model field handle. Configure first, then bind; the result is aUiElement. See Binding Controls.
The text input is the most common form element in Beverly.
It looks simple:
#![allow(unused)]
fn main() {
input()
}
But a production input has several responsibilities:
- Present a value
- Identify itself with a label
- Provide accessible semantics
- Bind to application state
- Convert user input into the Model’s type
- Invoke the appropriate setter
- Receive validation results
- Present field-level errors
- Reflect changes made by the Model
- Represent editing state such as touched, dirty, focused, and pending
Beverly keeps these responsibilities together without requiring the developer to manually wire them up.
Labels and Accessible Descriptions
Every input needs a persistent accessible name. An additional ARIA description is useful when the label alone does not explain the expected value or context.
#![allow(unused)]
fn main() {
input()
.label(localize("user.email.label"))
.aria(localize("user.email.description"))
}
The accessible name is essential; the description is optional context.
The label identifies the field.
The ARIA declaration provides additional accessible semantics or descriptive information.
If no explicit accessible label or floating label is present, Beverly warns at spawn time rather than treating the placeholder as the accessible name.
Accessible by construction, not accessible by cleanup.
In the concrete Rust API, TextInputConfig::label(...) sets the persistent
accessible name, while floating_label(...) supplies the visible floating
label. A placeholder is only a hint and is never used as the accessible name.
If neither an explicit accessible label nor a floating label is provided,
Beverly warns at spawn time rather than presenting the placeholder as a name:
#![allow(unused)]
fn main() {
use beverly::components::input::TextInputConfig;
let config = TextInputConfig::new("[email protected]")
.label("Email address")
.floating_label("Email");
}
This is particularly important for AI-generated interfaces. An agent should not have to remember accessibility requirements as a separate cleanup step. The component API makes the required structure explicit.
The Basic Input
A useful input normally has three fundamental pieces:
#![allow(unused)]
fn main() {
input()
.label(localize("user.email.label"))
.aria(localize("user.email.description"))
.bind(User::email)
}
The label identifies the field.
The ARIA declaration provides accessible context.
The binding connects the field to application state.
Everything else builds on these three pieces.
Binding
.bind() connects the input to a Model field.
#![allow(unused)]
fn main() {
input()
.label(localize("user.email.label"))
.aria(localize("user.email.description"))
.bind(User::email)
}
The binding handles the translation between interface input and typed application state.
Conceptually:
Model → Binding → Input
Input → Binding → Setter → Model
The Model remains the source of truth.
The developer does not need to manually write a change handler, synchronize state, or copy the input value into another form-specific state object.
The binding handles that connection.
The Setter Is Called on Every Change
A bound input invokes the Model setter every time the input changes.
For example, if a user types:
v
vi
vic
vict
victor
the corresponding setter is invoked for each change.
Input Change
↓
Binding
↓
Model Setter
↓
Result
This is intentional.
The Model sees the stream of values and decides what to do with them.
For a simple field, that might mean validating immediately.
For another field, it might mean normalizing the value.
For an asynchronous field, it might mean starting or scheduling a request.
The Form does not need to know which behavior is appropriate.
The input reports every change. The Model decides what every change means.
The Model Can Change the Input
Binding is not simply a one-way mechanism for sending text into the Model.
The Model can transform the value and send the canonical value back to the input.
Phone numbers are a good example.
A user might enter:
4155551234
The Model may normalize that value to:
(415) 555-1234
For example:
#![allow(unused)]
fn main() {
impl User {
fn set_phone(
&mut self,
phone: String,
) -> Result<(), UserError> {
let phone = format_phone_number(phone)?;
self.phone = phone;
Ok(())
}
}
}
The resulting flow is:
User Input
↓
Binding
↓
Model Setter
↓
Normalize / Validate
↓
Model State
↓
Binding
↓
Formatted Input
The input does not need to know how phone numbers are formatted.
The Model owns the canonical representation.
This applies to:
- Phone numbers
- Currency
- Dates
- Identifiers
- Capitalization
- Whitespace
- Units
- Domain-specific formats
The user edits the value.
The Model decides what the application’s value actually becomes.
The input collects the value. The Model defines the value.
The Setter Owns Validation
Validation belongs to the Model.
For example:
#![allow(unused)]
fn main() {
impl User {
fn set_email(
&mut self,
email: String,
) -> Result<(), UserError> {
let email = email.trim().to_lowercase();
if !is_valid_email(&email) {
return Err(UserError::InvalidEmail);
}
self.email = email;
Ok(())
}
}
}
The input does not know what makes an email valid.
The Form does not know either.
The Model knows.
That means the same validation rule applies whether the value comes from:
- A human using the Form
- An AI agent
- An API
- Another component
- Another internal application action
There is one validation rule and one place to maintain it.
From Setter Error to Field Error
Suppose the user enters an invalid email.
The binding passes the value to the Model:
Input
↓
Binding
↓
User::set_email(...)
↓
Err(UserError::InvalidEmail)
The setter rejects the state transition.
The Model does not change.
The binding receives the error and associates it with the field that produced it.
Field-level errors are therefore derived from the form binding.
Setter Result
↓
Binding
↓
Field Errors
↓
.error(...)
The UI does not independently validate the email.
It does not need to duplicate the Model’s rules.
The setter returned an error.
The binding knows which field invoked the setter.
The field therefore receives the error.
Errors Are Exposed to the Error Context
When .error(...) is configured, the current field errors are automatically exposed to the content inside the error hook.
The callback receives the field’s current errors as &[String] and returns
the content to display below the input. Both TextInputConfig::error(...)
and UiElement::error(...) use this contract; see
Error Callbacks for concrete examples.
The error context is an array of strings:
#![allow(unused)]
fn main() {
input()
.label(localize("user.email.label"))
.aria(localize("user.email.description"))
.bind(User::email)
.error(|errors| text(errors.join("\n")))
}
The important part is that the strings come from the Model’s error response.
The form element does not invent them.
The Model produces the errors.
The binding associates those errors with the field.
The .error(...) content presents them.
A field can therefore expose multiple errors when the Model returns multiple validation messages:
errors = [
"Email address is invalid.",
"Email domain is not allowed."
]
The error presentation can decide how those messages should be displayed.
For example:
#![allow(unused)]
fn main() {
.error(|errors| text(errors.join("\n")))
}
The exact presentation is the View’s responsibility.
The contents of the error are the Model’s responsibility.
The Model produces the errors. The binding exposes them. The field presents them.
Internationalization
Validation text should not be hard-coded into the form element.
Avoid:
#![allow(unused)]
fn main() {
.error(|errors| text(errors.join("\n")))
}
The Model should return the semantic validation error, and the resulting error context should provide the appropriate localized message.
Conceptually:
Model
↓
Validation Error
↓
Localized Error Message
↓
Binding
↓
Field Error Context
↓
.error(...)
This means the reusable input does not contain English validation messages—or validation messages in any particular language.
Labels and ARIA descriptions should likewise come from the application’s localization system:
#![allow(unused)]
fn main() {
input()
.label(localize("user.email.label"))
.aria(localize("user.email.description"))
.bind(User::email)
}
The component describes the structure of the interface.
The localization system provides the language.
The Model provides the meaning of validation failures.
The Model returns meaning. The UI presents language.
Asynchronous Validation
Some setters need to perform asynchronous work.
For example, an email address might need to be checked against a server to determine whether it is already registered.
The setter can represent that transition asynchronously:
#![allow(unused)]
fn main() {
impl User {
async fn set_email(
&mut self,
email: String,
) -> Result<(), UserError> {
let email = email.trim().to_lowercase();
if !is_valid_email(&email) {
return Err(UserError::InvalidEmail);
}
if email_exists(&email).await? {
return Err(UserError::EmailAlreadyRegistered);
}
self.email = email;
Ok(())
}
}
}
The input does not need a special asynchronous validation framework.
It still calls the setter on every change.
The Model determines that this particular transition requires asynchronous work.
Pending State
While the Model is performing asynchronous work, the field can expose its pending state.
For example:
#![allow(unused)]
fn main() {
input()
.label(localize("user.email.label"))
.aria(localize("user.email.description"))
.bind(User::email)
.submitting(|| [spinner()])
}
Or the pending presentation can contain additional content:
#![allow(unused)]
fn main() {
input()
.label(localize("user.email.label"))
.aria(localize("user.email.description"))
.bind(User::email)
.submitting(|| [
spinner(),
text(localize("user.email.checking"))
])
}
The same principle applies:
The application sets FormStatusState::submitting() before async work and
sets success() or idle() when it completes. The hook adds supplementary
content below the input; it does not observe the Model or restyle the input.
Debouncing Belongs in the Model
Because the setter is called on every input change, applications that perform expensive or remote work should debounce that work in the Model layer.
For example, typing:
v
vi
vic
vict
victor
should not necessarily produce five network requests.
The Model can debounce the request:
#![allow(unused)]
fn main() {
impl User {
async fn set_username(
&mut self,
username: String,
) -> Result<(), UserError> {
self.username = username.clone();
debounce(Duration::from_millis(300)).await;
if username_exists(&username).await? {
return Err(UserError::UsernameTaken);
}
Ok(())
}
}
}
The important architectural point is that the Form still reports every change.
The Model decides that remote validation should wait until the user pauses.
Every Input Change
↓
Setter
↓
Model
↓
Debounce
↓
Async Validation
↓
Model Result
↓
Binding
↓
Input
This keeps debounce behavior out of the UI.
If an agent, API, or another application component invokes the same state transition, the same Model behavior applies.
Avoiding Stale Requests
For remote validation, a production implementation should also prevent an older request from overwriting a newer result.
Conceptually:
#![allow(unused)]
fn main() {
impl User {
async fn set_username(
&mut self,
username: String,
) -> Result<(), UserError> {
self.username = username.clone();
let request_id = self.next_validation_request();
sleep(Duration::from_millis(300)).await;
let available = username_available(&username).await?;
if request_id != self.current_validation_request() {
return Ok(());
}
if !available {
return Err(UserError::UsernameTaken);
}
Ok(())
}
}
}
The exact implementation can be hidden behind Beverly’s runtime primitives.
The important rule is that the Model owns the lifecycle of the asynchronous validation.
The Form should not have to implement debounce timers, request cancellation, or stale-response handling.
Validation Timing
The Model also controls when a field should be considered invalid.
For example, an application may not want to display an error before a user has interacted with a field.
If validation behavior depends on whether the field has been touched, that state must participate in the Model’s state and logic.
Conceptually:
#![allow(unused)]
fn main() {
if touched {
validate(value)
} else {
Ok(())
}
}
The exact implementation can vary, but the architectural principle is important:
If validation behavior depends on state, that state belongs to the Model.
The Form can present the resulting state, but it should not secretly redefine the application’s validation rules.
Successful Updates
When the setter returns:
#![allow(unused)]
fn main() {
Ok(())
}
the Model has accepted the state transition.
The binding reflects the accepted value back into the input.
If the Model transformed the value, the transformed value is what the input displays.
For example:
User types: 4155551234
↓
set_phone()
↓
Model stores: (415) 555-1234
↓
Input displays: (415) 555-1234
The Model is always the authoritative state.
Error Updates
When the setter returns an error:
#![allow(unused)]
fn main() {
Err(...)
}
the Model rejects the transition.
The previous valid Model value remains authoritative.
The binding exposes the resulting errors to the field.
The field’s .error(...) presentation becomes active.
Input
↓
Setter
↓
Err
↓
Field Error Context
↓
.error(...)
The UI does not have to manually set an error flag.
The error is a consequence of the failed Model transition.
Styling the Input
Inputs use the same fluent API as every other Beverly component.
#![allow(unused)]
fn main() {
input()
.label(localize("user.email.label"))
.aria(localize("user.email.description"))
.bind(User::email)
.padding(12)
.radius(8)
.width(320)
}
State-specific presentation uses the same API:
#![allow(unused)]
fn main() {
input()
.label(localize("user.email.label"))
.aria(localize("user.email.description"))
.bind(User::email)
.error(|errors| text(errors.join("\n")))
}
Or:
#![allow(unused)]
fn main() {
input()
.label(localize("user.email.label"))
.aria(localize("user.email.description"))
.bind(User::email)
.error(|errors| text(errors.join("\n")))
}
The component API does not change because the input is in an error state.
The Complete Input
A production input might therefore look like:
#![allow(unused)]
fn main() {
input()
.label(localize("user.email.label"))
.aria(localize("user.email.description"))
.bind(User::email)
.padding(12)
.radius(8)
.width(320)
.error(|errors| text(errors.join("\n")))
.submitting(|| [spinner()])
}
The input has:
- A required label
- A required ARIA declaration
- A Model binding
- Typed application state
- Model-owned validation
- Model-owned normalization
- Automatic synchronization
- Setter error propagation
- An automatically exposed error context
- Localized error presentation
- Async/pending state
- Fluent styling
And there is no manual on_change handler.
The Text Input Contract
The text input can be understood as a contract between the Form and the Model:
TEXT INPUT
Label ──────────────── Identity
ARIA ───────────────── Accessibility
Bind ───────────────── State connection
Input ──────────────── User intent
Setter ─────────────── Validation + transformation
Result ─────────────── Accepted value or errors
Model ──────────────── Source of truth
The input reports every change.
The binding connects that change to the Model.
The setter validates and transforms it.
The Model can synchronously or asynchronously change application state.
The binding reflects the resulting state back into the input.
Errors come from the Model and are exposed through the binding.
The field presents those errors.
The input collects. The binding connects. The setter validates and transforms. The Model owns the state. The field presents the result.
Textarea
Live Demo
Open the demo in a separate tab.
Model binding:
TextareaConfig::new("Notes").label("Notes").bind(User::notes)connects a text field to the textarea and returns a composableUiElement. Validation messages live inBindingErrorState;.error(|errors| ...)renders their visible presentation. See Error Callbacks.
textarea() is the multiline counterpart to input().
Disabled and Read-Only State
#![allow(unused)]
fn main() {
TextareaConfig::new("Notes")
.label("Notes")
.required(true)
.read_only(true)
}
.disabled(true) removes the initial tab stop and rejects focus and editing.
.read_only(true) keeps content available for focus, selection, and copying
while rejecting insertion/deletion/cut/paste mutations. Both states and
required are projected to AccessKit. Bound validation automatically exposes
invalid state and related error text; visible error styling remains app-owned.
It follows the same patterns:
- A label is required.
- An ARIA declaration is required.
.bind()connects it to Model state.- The Model setter is called every time the value changes.
- The Model validates and transforms the value.
- Setter errors become field-level errors through the binding.
- Errors are exposed to the
.error(...)context as an array of strings. - The Model can perform asynchronous validation.
- Pending state is represented by the component.
- Debouncing belongs in the Model.
- The Model remains the source of truth.
The only fundamental difference is that textarea() is designed for multiline content.
In the concrete Rust API, set the accessible name with
TextareaConfig::label(...). The placeholder is supplementary guidance, not
a substitute for a label. When no accessible label is supplied, Beverly warns
at spawn time instead of treating placeholder text as the name.
#![allow(unused)]
fn main() {
use beverly::components::textarea::TextareaConfig;
let config = TextareaConfig::new("A short biography")
.label("Biography");
}
The Basic Textarea
A basic textarea:
#![allow(unused)]
fn main() {
textarea()
.label(localize("profile.bio.label"))
.aria(localize("profile.bio.description"))
}
A bound textarea:
#![allow(unused)]
fn main() {
textarea()
.label(localize("profile.bio.label"))
.aria(localize("profile.bio.description"))
.bind(User::bio)
}
The API should feel immediately familiar to anyone who has used an HTML textarea, while the underlying behavior follows Beverly’s Model and binding architecture.
Binding
.bind() connects the textarea to a Model property:
#![allow(unused)]
fn main() {
textarea()
.label(localize("profile.bio.label"))
.aria(localize("profile.bio.description"))
.bind(User::bio)
}
The textarea reads its initial value from the Model.
When the user types, the setter is invoked.
When the Model changes, the textarea changes.
Model → Binding → Textarea
Textarea → Binding → Setter → Model
There is no second authoritative copy of the text inside the textarea.
The Setter Is Called on Every Change
Just like input(), the Model setter is called whenever the textarea value changes.
If a user types:
H
He
Hel
Hell
Hello
the setter receives those changes.
Textarea Change
↓
Binding
↓
Model Setter
↓
Result
The Model can then decide what to do with each change.
It can:
- Accept the value
- Reject the value
- Normalize the value
- Transform the value
- Track editing state
- Perform asynchronous validation
- Debounce expensive work
The textarea does not need to know which behavior is being used.
The Model Can Transform the Text
The Model can modify the value and send the resulting value back through the binding.
For example, suppose a description should never contain leading or trailing whitespace:
#![allow(unused)]
fn main() {
impl User {
fn set_bio(
&mut self,
bio: String,
) -> Result<(), UserError> {
let bio = bio.trim().to_string();
self.bio = bio;
Ok(())
}
}
}
Or the Model might normalize repeated whitespace, enforce a domain-specific format, or transform the content before storing it.
The resulting flow is:
User Text
↓
Setter
↓
Normalize
↓
Model State
↓
Binding
↓
Textarea
The Model determines the canonical value.
Validation
Textarea validation works exactly like input validation.
Suppose a biography must contain at least 20 characters:
#![allow(unused)]
fn main() {
impl User {
fn set_bio(
&mut self,
bio: String,
) -> Result<(), UserError> {
let bio = bio.trim().to_string();
if bio.chars().count() < 20 {
return Err(UserError::BioTooShort);
}
self.bio = bio;
Ok(())
}
}
}
The textarea does not implement this rule.
The Model does.
This means the same rule applies regardless of whether the value comes from a human, an agent, an API, or another application component.
Field Errors
If the setter returns an error, the binding associates that error with the textarea.
#![allow(unused)]
fn main() {
textarea()
.label(localize("profile.bio.label"))
.aria(localize("profile.bio.description"))
.bind(User::bio)
.error(|errors| text(errors.join("\n")))
}
The error context is automatically exposed to the .error(...) content.
The errors are an array of strings.
The strings originate from the Model’s validation response.
Model Setter
↓
Errors
[String, String, ...]
↓
Binding
↓
Textarea Error Context
↓
.error(...)
The textarea does not generate the validation message.
The Model does.
Internationalization
Validation messages should never be hard-coded into the textarea.
Instead of:
#![allow(unused)]
fn main() {
text("Biography is too short.")
}
the Model returns the appropriate structured error, and the error exposed by the binding contains the localized message.
#![allow(unused)]
fn main() {
textarea()
.label(localize("profile.bio.label"))
.aria(localize("profile.bio.description"))
.bind(User::bio)
.error(|errors| text(errors.join("\n")))
}
The textarea contains no language-specific validation copy.
The label and ARIA description come from the application’s localization system.
The error comes from the Model.
The Model returns meaning. The UI presents language.
Multiple Errors
A Model may return more than one error for a field.
For example:
[
"Biography is too short.",
"Biography contains prohibited content."
]
The error context exposes the errors together.
The View decides how to present them.
For example:
#![allow(unused)]
fn main() {
textarea()
.label(localize("profile.bio.label"))
.aria(localize("profile.bio.description"))
.bind(User::bio)
.error(|errors| text(errors.join("\n")))
}
The Model determines the errors.
The component determines their presentation.
Asynchronous Validation
Textarea values can also require asynchronous validation.
For example, a content field might need to be checked by a remote service.
The Model owns that behavior:
#![allow(unused)]
fn main() {
impl User {
async fn set_bio(
&mut self,
bio: String,
) -> Result<(), UserError> {
let bio = bio.trim().to_string();
if bio.chars().count() < 20 {
return Err(UserError::BioTooShort);
}
if !content_allowed(&bio).await? {
return Err(UserError::ContentNotAllowed);
}
self.bio = bio;
Ok(())
}
}
}
The textarea does not need an async validation API.
The setter is still the state transition.
The difference is that the Model performs asynchronous work before accepting that transition.
Pending State
While asynchronous validation is occurring, the textarea can expose a pending presentation:
#![allow(unused)]
fn main() {
textarea()
.label(localize("profile.bio.label"))
.aria(localize("profile.bio.description"))
.bind(User::bio)
.submitting(|| [spinner()])
}
Or:
#![allow(unused)]
fn main() {
textarea()
.label(localize("profile.bio.label"))
.aria(localize("profile.bio.description"))
.bind(User::bio)
.submitting(|| [
spinner(),
text(localize("profile.bio.checking"))
])
}
Again, self refers to the textarea itself.
The async Event or setter does not directly manipulate the textarea.
The application sets FormStatusState::submitting() while that work is pending.
The textarea observes that state and presents itself accordingly.
The callback supplies supplementary content below the textarea; set idle() or
success() to replace or remove it.
Debouncing
The setter is called for every textarea change.
That means debouncing expensive work belongs in the Model.
Suppose content validation requires a remote request. A user typing a paragraph should not trigger a network request for every character.
The Model can debounce the work:
#![allow(unused)]
fn main() {
impl User {
async fn set_bio(
&mut self,
bio: String,
) -> Result<(), UserError> {
self.bio = bio.clone();
debounce(Duration::from_millis(300)).await;
validate_content(&bio).await?;
Ok(())
}
}
}
The textarea continues to report every change.
The Model decides when the expensive operation should actually happen.
Every Change
↓
Setter
↓
Model
↓
Debounce
↓
Async Validation
↓
Result
↓
Binding
↓
Textarea
This keeps timing and network behavior out of the View.
The same behavior therefore applies if the Model transition is initiated by another application client.
Touched State
The Model can also control when validation should occur.
For example, an application may not want to show a biography error until the user has interacted with the field.
If the validation rules depend on touched, that state participates in the Model’s logic.
#![allow(unused)]
fn main() {
if touched {
validate_bio(&bio)
} else {
Ok(())
}
}
The important principle is:
If validation behavior depends on state, that state belongs to the Model.
The textarea presents the result.
It does not invent the rule.
Styling
Textarea uses the same fluent component API:
#![allow(unused)]
fn main() {
textarea()
.label(localize("profile.bio.label"))
.aria(localize("profile.bio.description"))
.bind(User::bio)
.padding(12)
.radius(8)
.width(400)
.height(160)
}
Error presentation follows the same pattern:
#![allow(unused)]
fn main() {
textarea()
.label(localize("profile.bio.label"))
.aria(localize("profile.bio.description"))
.bind(User::bio)
.error(|errors| text(errors.join("\n")))
}
Pending presentation:
#![allow(unused)]
fn main() {
textarea()
.label(localize("profile.bio.label"))
.aria(localize("profile.bio.description"))
.bind(User::bio)
.submitting(|| [spinner()])
}
The same component API handles normal, error, and pending states.
The Complete Textarea
A production textarea can combine all of these capabilities:
#![allow(unused)]
fn main() {
textarea()
.label(localize("profile.bio.label"))
.aria(localize("profile.bio.description"))
.bind(User::bio)
.padding(12)
.radius(8)
.width(400)
.height(160)
.error(|errors| text(errors.join("\n")))
.submitting(|| [spinner()])
}
There is no manual change handler.
There is no second source of truth.
There is no UI-specific validation implementation.
There is no hard-coded validation language.
The input reports changes.
The binding connects those changes to the Model.
The setter validates and transforms the value.
The Model can perform asynchronous work.
The binding exposes the resulting errors.
The textarea presents the resulting state.
Textarea is not a special form system. It is the same Beverly binding contract applied to multiline text.
Checkbox
Live Demo
Open the demo in a separate tab.
Model binding:
CheckboxConfig::new().label("Active").bind(User::active)connects a boolean model field to the checkbox. Configure first, then bind; the result is a composableUiElement. See Binding Controls.
checkbox() is the standard Beverly form element for boolean values.
Like every Beverly form element, a checkbox requires:
- A label
- An ARIA declaration
- A Model binding
#![allow(unused)]
fn main() {
checkbox()
.label(localize("user.active.label"))
.aria(localize("user.active.description"))
.bind(User::active)
}
The checkbox does not own the boolean value.
The Model does.
Binding
A checkbox binds directly to a boolean Model property:
#![allow(unused)]
fn main() {
checkbox()
.label(localize("user.active.label"))
.aria(localize("user.active.description"))
.bind(User::active)
}
The binding works in both directions:
Model → Binding → Checkbox
Checkbox → Binding → Setter → Model
If the Model contains:
#![allow(unused)]
fn main() {
active: true
}
the checkbox is checked.
If the user unchecks it, the binding sends false to the Model.
If another part of the application changes active, the checkbox reflects the new Model state.
The Setter Is Called on Every Change
Just like input() and textarea(), the setter is invoked whenever the checkbox changes.
Checked
↓
true
↓
Setter
Unchecked
↓
false
↓
Setter
For a simple boolean, the setter may be straightforward:
#![allow(unused)]
fn main() {
impl User {
fn set_active(
&mut self,
active: bool,
) -> Result<(), UserError> {
self.active = active;
Ok(())
}
}
}
The Model can still enforce domain rules.
For example, perhaps a user cannot deactivate themselves while they are the only administrator:
#![allow(unused)]
fn main() {
impl User {
fn set_active(
&mut self,
active: bool,
) -> Result<(), UserError> {
if !active && self.is_only_admin() {
return Err(UserError::CannotDeactivateOnlyAdmin);
}
self.active = active;
Ok(())
}
}
}
The checkbox does not need to understand that rule.
The Model does.
The Model Can Reject a Change
Suppose the user attempts to uncheck the checkbox.
The binding invokes the setter:
Checkbox
↓
Binding
↓
User::set_active(false)
↓
Err(UserError::CannotDeactivateOnlyAdmin)
The Model rejects the state transition.
The existing Model value remains authoritative.
The binding receives the error and exposes it to the checkbox’s error context.
#![allow(unused)]
fn main() {
checkbox()
.label(localize("user.active.label"))
.aria(localize("user.active.description"))
.bind(User::active)
.error(|errors| text(errors.join("\n")))
}
The errors are derived from the binding.
The checkbox does not invent them.
The Model decides whether the checkbox can change. The checkbox presents the result.
Error Context
Just like the text input and textarea, the checkbox’s .error(...) callback
receives &[String] from the current validation state and returns visible
content below the control. See Error Callbacks.
#![allow(unused)]
fn main() {
.error(|errors| text(errors.join("\n")))
}
Two things are automatically available inside the state hook:
self— the checkbox itselferrors— the current field errors as&[String]
The Model supplies the errors.
The binding associates them with the checkbox.
The View determines how they are presented.
Internationalization
Validation messages should not be hard-coded into the checkbox.
Avoid:
#![allow(unused)]
fn main() {
text("You cannot deactivate yourself.")
}
Instead, the Model returns a structured error and the error context exposes the localized message.
#![allow(unused)]
fn main() {
checkbox()
.label(localize("user.active.label"))
.aria(localize("user.active.description"))
.bind(User::active)
.error(|errors| text(errors.join("\n")))
}
The label and ARIA description come from localization.
The validation error comes from the Model.
This means the checkbox contains no language-specific validation logic.
The Model returns meaning. The UI presents language.
The Model Can Transform State
A checkbox normally represents a boolean directly, but the Model can still perform additional work when the value changes.
For example, changing active might update related application state:
#![allow(unused)]
fn main() {
impl User {
fn set_active(
&mut self,
active: bool,
) -> Result<(), UserError> {
self.active = active;
self.update_access_state();
Ok(())
}
}
}
The checkbox does not need to know what update_access_state() does.
It simply reflects the resulting Model state.
This is the same principle as phone-number formatting with input().
The form element reports intent. The Model determines the resulting state.
Asynchronous Changes
A checkbox can also trigger an asynchronous Model transition.
For example, activating an account might require a remote operation:
#![allow(unused)]
fn main() {
impl User {
async fn set_active(
&mut self,
active: bool,
) -> Result<(), UserError> {
update_account_status(active).await?;
self.active = active;
Ok(())
}
}
}
The checkbox can represent the pending state:
#![allow(unused)]
fn main() {
checkbox()
.label(localize("user.active.label"))
.aria(localize("user.active.description"))
.bind(User::active)
.submitting(|| [spinner()])
}
Or:
#![allow(unused)]
fn main() {
checkbox()
.label(localize("user.active.label"))
.aria(localize("user.active.description"))
.bind(User::active)
.submitting(|| [
spinner(),
text(localize("user.active.updating"))
])
}
Again, self refers to the checkbox.
The async operation does not directly manipulate the component.
The application sets FormStatusState::submitting() and sets idle() or
success() when the operation changes state. The callback supplies content
below the checkbox; it does not style or disable the checkbox.
No Manual Change Handler
A checkbox should not require code like:
#![allow(unused)]
fn main() {
on_change(|checked| {
// manually update the Model
})
}
The binding already establishes that relationship.
Instead:
#![allow(unused)]
fn main() {
checkbox()
.label(localize("user.active.label"))
.aria(localize("user.active.description"))
.bind(User::active)
}
is enough.
The framework knows:
- The checkbox represents
User::active. - The current Model value determines whether it is checked.
- A user change invokes the corresponding setter.
- The setter can accept, reject, transform, or asynchronously process the change.
- The resulting Model state is reflected back into the checkbox.
Styling
Checkboxes use the same fluent component API:
#![allow(unused)]
fn main() {
checkbox()
.label(localize("user.active.label"))
.aria(localize("user.active.description"))
.bind(User::active)
.gap(8)
}
Error presentation:
#![allow(unused)]
fn main() {
checkbox()
.label(localize("user.active.label"))
.aria(localize("user.active.description"))
.bind(User::active)
.error(|errors| text(errors.join("\n")))
}
Pending presentation:
#![allow(unused)]
fn main() {
checkbox()
.label(localize("user.active.label"))
.aria(localize("user.active.description"))
.bind(User::active)
.submitting(|| [spinner()])
}
The same API works for normal, error, and pending states.
The Complete Checkbox
A production checkbox might look like:
#![allow(unused)]
fn main() {
checkbox()
.label(localize("user.active.label"))
.aria(localize("user.active.description"))
.bind(User::active)
.gap(8)
.error(|errors| text(errors.join("\n")))
.submitting(|| [spinner()])
}
The checkbox has:
- A required label
- A required ARIA declaration
- A typed
boolbinding - Two-way Model synchronization
- Model-owned validation
- Model-owned state transitions
- Model-owned asynchronous behavior
- Automatic error propagation
- Localized error presentation
- Pending-state presentation
- Fluent styling
The checkbox itself remains simple.
The checkbox represents a boolean. The binding connects it. The setter decides whether that boolean can change. The Model owns the state.
Radio
Live Demo
Open the demo in a separate tab.
Model binding:
RadioGroupBuilder::new("plan").option("free", "Free").bind(User::plan)connects the group’s string-valued selection to a model field and returnsUiElement. See Binding Controls.
RadioGroupBuilder::error(|errors| ...) renders the group’s current validation
errors below its options. See Error Callbacks.
A radio control represents a choice from a set of mutually exclusive options.
A radio can be used as a group of options or, when the application only needs a single explicit choice, as a singular radio control.
The underlying contract is the same as every Beverly form element:
The radio represents the choice. The binding connects it. The setter decides whether the choice can change. The Model owns the state.
Radio Groups
The most common use is a group of mutually exclusive options.
For example, a user selecting a subscription plan:
#![allow(unused)]
fn main() {
radio_group()
.label(localize("subscription.plan.label"))
.aria(localize("subscription.plan.description"))
.bind(User::plan)
.children([
radio("free")
.label(localize("subscription.plan.free.label"))
.aria(localize("subscription.plan.free.description")),
radio("pro")
.label(localize("subscription.plan.pro.label"))
.aria(localize("subscription.plan.pro.description")),
radio("enterprise")
.label(localize("subscription.plan.enterprise.label"))
.aria(localize("subscription.plan.enterprise.description")),
])
}
Only one option can be selected at a time.
The group binds to the Model field:
#![allow(unused)]
fn main() {
#[model]
struct User {
plan: Plan,
}
}
The Model owns the selected value:
#![allow(unused)]
fn main() {
enum Plan {
Free,
Pro,
Enterprise,
}
}
The radio group is therefore not its own state-management system. It is a visual and interactive representation of User::plan.
Binding
Binding works the same way as input(), textarea(), and checkbox().
Model → Binding → Radio Group
Radio → Binding → Setter → Model
When the Model changes, the selected radio changes.
When the user selects a radio, the binding sends the new value to the Model.
The setter is called whenever the selection changes.
#![allow(unused)]
fn main() {
impl User {
fn set_plan(
&mut self,
plan: Plan,
) -> Result<(), UserError> {
if self.requires_upgrade_confirmation(&plan) {
return Err(UserError::UpgradeRequiresConfirmation);
}
self.plan = plan;
Ok(())
}
}
}
The UI does not need to reproduce this rule.
An agent, API, or another part of the application changing User::plan uses the same Model mechanics.
The UI is one client of the application. It is not the authority over the application.
Singular Radio
A radio can also be used as a single explicit choice.
This is useful when the application wants a boolean-like decision but wants the visual semantics of a radio control.
#![allow(unused)]
fn main() {
radio("yes")
.label(localize("user.contact.label"))
.aria(localize("user.contact.description"))
.bind(User::contact_me)
}
The same binding rules apply.
If the application instead needs a true/false toggle, checkbox() is generally the more direct representation. Radio is most naturally used when the user is choosing among alternatives.
Radio Options Are Data
Radio groups become particularly useful when options come from application data.
#![allow(unused)]
fn main() {
radio_group()
.label(localize("subscription.plan.label"))
.aria(localize("subscription.plan.description"))
.bind(User::plan)
.children(
plans.iter().map(|plan| {
radio(plan.id())
.label(localize(plan.label_key()))
.aria(localize(plan.description_key()))
})
)
}
The View does not need to know where the options came from.
They can come from:
- Model state
- A database
- An API
- Configuration
- A local file
- A stream
- AI-generated application data
The View simply renders the available choices.
Validation
Validation belongs in the Model.
For example, the application might prevent a user from selecting an unavailable plan:
#![allow(unused)]
fn main() {
impl User {
fn set_plan(
&mut self,
plan: Plan,
) -> Result<(), UserError> {
if !self.available_plans.contains(&plan) {
return Err(UserError::PlanUnavailable);
}
self.plan = plan;
Ok(())
}
}
}
The radio does not need to know why the option is unavailable.
If the setter rejects the change, the Model remains unchanged and the binding keeps the radio selection synchronized with the actual application state.
Field Errors
Errors belong to the binding associated with the radio group.
#![allow(unused)]
fn main() {
radio_group()
.label(localize("subscription.plan.label"))
.aria(localize("subscription.plan.description"))
.bind(User::plan)
.children([
radio("free")
.label(localize("subscription.plan.free.label"))
.aria(localize("subscription.plan.free.description")),
radio("pro")
.label(localize("subscription.plan.pro.label"))
.aria(localize("subscription.plan.pro.description")),
])
.error(|errors| text(errors.join("\n")))
}
The error text is not hard-coded into the View.
The Model returns the semantic error. Beverly exposes the resulting error context to the field presentation.
The Model returns meaning. The UI presents language.
Async Changes
A radio selection can trigger asynchronous Model logic just like any other bound field.
#![allow(unused)]
fn main() {
impl User {
async fn set_plan(
&mut self,
plan: Plan,
) -> Result<(), UserError> {
check_plan_availability(&plan).await?;
self.plan = plan;
Ok(())
}
}
}
While the transition is pending, the radio group can present a submitting state:
#![allow(unused)]
fn main() {
radio_group()
.label(localize("subscription.plan.label"))
.aria(localize("subscription.plan.description"))
.bind(User::plan)
.submitting(|| [spinner()])
}
Or provide a richer pending presentation:
#![allow(unused)]
fn main() {
radio_group()
.label(localize("subscription.plan.label"))
.aria(localize("subscription.plan.description"))
.bind(User::plan)
.submitting(|| [
spinner(),
text(localize("subscription.plan.updating")),
])
}
The self here refers to the radio group itself.
It does not refer to the Event that caused the state change.
The Event changes application state. The component hook declares how the component should present itself while that state is active.
The app sets and clears FormStatusState; the callback supplies supplementary
content below the RadioGroup.
Accessibility
Accessibility is part of the radio’s definition, not cleanup work.
The radio group has a label and accessible description:
#![allow(unused)]
fn main() {
radio_group()
.label(localize("subscription.plan.label"))
.aria(localize("subscription.plan.description"))
}
Each individual option also has its own label and accessible description:
#![allow(unused)]
fn main() {
radio("pro")
.label(localize("subscription.plan.pro.label"))
.aria(localize("subscription.plan.pro.description"))
}
A radio without its required accessibility metadata is not a valid Beverly form element.
Beverly can therefore catch missing required accessibility declarations at compile time rather than relying entirely on manual review.
Accessible by construction, not accessible by cleanup.
Styling
Radio groups and individual radio controls use the same chained styling API as other Beverly components.
#![allow(unused)]
fn main() {
radio_group()
.padding(16)
.radius(12)
.gap(8)
.width(400)
}
Individual options can be styled independently:
#![allow(unused)]
fn main() {
radio("pro")
.label(localize("subscription.plan.pro.label"))
.aria(localize("subscription.plan.pro.description"))
.padding(8)
.radius(8)
}
State-specific styling follows the same pattern:
#![allow(unused)]
fn main() {
radio_group()
.error(|errors| text(errors.join("\n")))
.submitting(|| [spinner()])
}
The same API works because these are not special radio behaviors. They are component state presentations.
Complete Example
Putting the pieces together:
#![allow(unused)]
fn main() {
radio_group()
.label(localize("subscription.plan.label"))
.aria(localize("subscription.plan.description"))
.bind(User::plan)
.children([
radio("free")
.label(localize("subscription.plan.free.label"))
.aria(localize("subscription.plan.free.description")),
radio("pro")
.label(localize("subscription.plan.pro.label"))
.aria(localize("subscription.plan.pro.description")),
radio("enterprise")
.label(localize("subscription.plan.enterprise.label"))
.aria(localize("subscription.plan.enterprise.description")),
])
.error(|errors| text(errors.join("\n")))
.submitting(|| [spinner()])
}
There is no manual change handler.
There is no second copy of plan inside the View.
There is no UI-specific validation system.
The Model owns the value. The binding keeps the interface synchronized. The radio group presents the available choices.
Radio API
| API | Purpose |
|---|---|
radio_group() | Creates a group of mutually exclusive choices |
radio(value) | Creates an individual radio option |
.children([...]) | Adds radio options to the group |
.label(...) | Defines the accessible name |
.aria(...) | Defines additional accessible semantics or description |
.bind(...) | Connects the group to Model state |
.error(...) | Defines the field-error presentation |
.submitting(...) | Defines the pending-state presentation |
.on(...) | Connects Events or Commands |
.padding(...), .gap(...), .radius(...), etc. | Applies styling |
Radio follows the same Beverly philosophy as every other form element:
Create. Configure. Compose. Bind. Declare state. Emit intent.
The radio is simple because the system around it is consistent.
Slider
A slider selects a numeric value within a range. Slider stores the value;
SliderStyle controls the track, fill, thumb, and optional value display.
BeverlyPlugin installs the slider systems.
Validation Errors
Use .bind(...) with .error(...) to render rejected numeric edits below the
slider:
#![allow(unused)]
fn main() {
Slider::new(0.0, 100.0)
.bind(User::rating)
.error(|errors| text(errors.join("\n")))
}
Slider::error(...) also supports manual BindingErrorState without binding.
See Error Callbacks for lifecycle and setup.
Live Demo
Open the demo in a separate tab.
Basic Usage
#![allow(unused)]
fn main() {
use beverly::components::slider::{Slider, SliderStyle, spawn_slider};
use beverly::prelude::ThemeResource;
use bevy::prelude::*;
fn add_slider(parent: &mut ChildSpawnerCommands, theme: &ThemeResource) {
spawn_slider(
parent,
Slider::new(0.0, 100.0).value(42.0).step(1.0).label("Volume"),
SliderStyle::default(),
theme,
);
}
}
Use .disabled(true) to prevent interaction. Provide .label(...) as the
accessible name, and keep a visible label beside the control.
Reading Changes
Read SliderChanged with MessageReader<SliderChanged> in an update system.
Each message contains the slider entity, its numeric value, and whether it
is currently dragging. The example uses the entity to update only the matching
readout; application state remains owned by the application.
Toggle Switch
A toggle switch represents an on/off setting. ToggleConfig supplies the label,
initial checked state, and disabled state; the spawned Toggle component holds
the current state. BeverlyPlugin installs its interaction and visual systems.
Live Demo
Open the demo in a separate tab.
Basic Usage
#![allow(unused)]
fn main() {
use beverly::components::toggle::{ToggleConfig, ToggleShadowMaterial, spawn_toggle};
use beverly::prelude::ThemeResource;
use bevy::prelude::*;
fn add_toggle(
parent: &mut ChildSpawnerCommands,
theme: &ThemeResource,
materials: &mut Assets<ToggleShadowMaterial>,
) -> Entity {
spawn_toggle(
parent,
ToggleConfig::new().label("Notifications").checked(true),
theme,
materials,
)
}
}
Pass the application’s ResMut<Assets<ToggleShadowMaterial>> into this helper;
do not create a temporary asset collection. Use .disabled(true) for a locked
setting, or .thumb_icons(...) to configure the two thumb icons.
Read Toggle::checked through a query when synchronizing application settings
or status text. The demo counts checked switches, including the disabled one.
Model binding is optional. Use ToggleConfig::new().bind(User::enabled) to
connect a boolean model field, or handle toggle events explicitly.
Use .error(|errors| text(errors.join("\n"))) on a ToggleConfig or its bound
UiElement to render validation messages below the switch. See
Error Callbacks.
Select
Model binding:
Select::new(vec!["free".to_owned()]).bind(User::plan)connects a typed Select to a model field and returnsUiElement. See Binding Controls.
Select<T>::error(|errors| ...) and bound fluent selects render the current
error messages below the control. Typed selects preserve their actual option
type and can use application-owned BindingErrorState even without automatic
field binding. See Error Callbacks.
select() lets a user choose a value from a list of available choices.
It follows the same Beverly form-element contract as input(), textarea(), checkbox(), and radio_group():
The Select presents choices. The binding connects them. The setter validates and transforms the value. The Model owns the state.
The individual choices are represented by option(), which is covered separately.
Concrete Rust API
The component API is generic: Select<T> stores the actual option values, and
SelectChanged<T> reports the selected value as T together with the select
entity and option index. T must be Clone + Display + Send + Sync + 'static;
its Display output is also used as the default accessible option label.
BeverlyPlugin registers SelectPlugin<String>. For each additional concrete
option type, register its plugin once:
#![allow(unused)]
fn main() {
use beverly::components::select::spawn_select;
use beverly::prelude::{Select, SelectPlugin};
app.add_plugins(SelectPlugin::<Region>::default());
}
Then create the UI under a parent with spawn_select, supplying the options
and a font handle:
#![allow(unused)]
fn main() {
use beverly::components::select::{spawn_select, Select};
spawn_select(parent, Select::new(regions), font_handle);
}
Read SelectChanged<Region> (from beverly::components::select) as a Bevy
message to receive the selected typed value. .bind(...) provides automatic
two-way synchronization when T implements BindingValueType + Display.
Built-in conversions cover strings and numeric scalar values; custom types can
implement BindingValueType to map to a model field. Enum-valued selects can
also use typed messages when no binding conversion is appropriate.
Basic Select
A Select binds directly to Model state:
#![allow(unused)]
fn main() {
select()
.label(localize("user.country.label"))
.aria(localize("user.country.description"))
.bind(User::country)
}
The Model owns the selected value:
#![allow(unused)]
fn main() {
#[model]
struct User {
country: Country,
}
}
The Select does not maintain a second authoritative copy of country.
When the Model changes, the Select reflects the new value.
When the user makes a selection, the binding sends the new value to the Model.
Model → Binding → Select
Select → Binding → Setter → Model
Options
Options are added through children():
#![allow(unused)]
fn main() {
select()
.label(localize("user.country.label"))
.aria(localize("user.country.description"))
.bind(User::country)
.children([
option("us")
.label(localize("country.us")),
option("ca")
.label(localize("country.ca")),
option("id")
.label(localize("country.id")),
])
}
select() owns the selection behavior.
option() represents an individual available choice.
This separation keeps the API composable and makes the same options reusable wherever appropriate.
Data-Driven Options
Options can come directly from application data.
#![allow(unused)]
fn main() {
select()
.label(localize("user.country.label"))
.aria(localize("user.country.description"))
.bind(User::country)
.children(
countries.iter().map(|country| {
option(country.code())
.label(localize(country.label_key()))
})
)
}
The options might come from:
- Model state
- A database
- An API
- Configuration
- A local file
- A stream
- AI-generated data
The Select only needs to render the choices provided to it.
Binding
Binding handles the conversion between the selected option and the typed Model value.
#![allow(unused)]
fn main() {
select()
.label(localize("user.role.label"))
.aria(localize("user.role.description"))
.bind(User::role)
}
When the user selects a different option, the Model setter is called.
For example:
#![allow(unused)]
fn main() {
impl User {
fn set_role(
&mut self,
role: Role,
) -> Result<(), UserError> {
if !self.allowed_roles.contains(&role) {
return Err(UserError::RoleNotAllowed);
}
self.role = role;
Ok(())
}
}
}
The Select does not need to know why a role is unavailable.
The Model owns that rule.
If the setter rejects the change, the Model remains unchanged and the Select remains synchronized with the actual application state.
The input reports the choice. The Model decides what that choice means.
Transformation
The setter can also transform the selected value before storing it.
#![allow(unused)]
fn main() {
impl User {
fn set_country(
&mut self,
country: String,
) -> Result<(), UserError> {
let country = normalize_country_code(&country)?;
self.country = country;
Ok(())
}
}
}
The Select does not need to understand normalization.
The binding translates the selection into the Model’s type, and the Model remains the source of truth.
Validation Errors
Validation errors belong to the bound field.
#![allow(unused)]
fn main() {
select()
.label(localize("user.role.label"))
.aria(localize("user.role.description"))
.bind(User::role)
.error(|errors| text(errors.join("\n")))
}
The error context is supplied automatically to the field presentation.
self refers to the Select itself.
errors represents the current errors associated with its binding.
The UI does not hard-code validation messages. The Model returns the semantic error, and the application’s localization layer provides the appropriate language.
The Model returns meaning. The UI presents language.
Async Validation
A selection can trigger asynchronous validation or state transitions.
#![allow(unused)]
fn main() {
impl User {
async fn set_role(
&mut self,
role: Role,
) -> Result<(), UserError> {
check_role_availability(&role).await?;
self.role = role;
Ok(())
}
}
}
While the transition is pending, the Select can change its presentation:
#![allow(unused)]
fn main() {
select()
.label(localize("user.role.label"))
.aria(localize("user.role.description"))
.bind(User::role)
.submitting(|| [spinner()])
}
Or provide a richer pending presentation:
#![allow(unused)]
fn main() {
select()
.label(localize("user.role.label"))
.aria(localize("user.role.description"))
.bind(User::role)
.submitting(|| [
spinner(),
text(localize("user.role.updating")),
])
}
Again, self refers to the Select component.
It does not refer to the Event that caused the state change.
The Event or Model transition changes state. The component hook declares how the Select presents that state.
The app sets and clears FormStatusState; the callback supplies supplementary
content below the Select.
Styling
Select uses the same fluent styling API as other Beverly components:
#![allow(unused)]
fn main() {
select()
.padding(12)
.radius(8)
.width(300)
}
State-specific styling uses the same mechanism:
#![allow(unused)]
fn main() {
select()
.error(|errors| text(errors.join("\n")))
.submitting(|| [spinner()])
}
There is no separate styling system for form states.
The component API remains consistent.
Accessibility
A Select is not a valid Beverly form element without its required accessibility metadata.
At minimum, the Select declares both its label and accessible description:
#![allow(unused)]
fn main() {
select()
.label(localize("user.country.label"))
.aria(localize("user.country.description"))
.bind(User::country)
}
The label identifies the control.
The ARIA declaration provides additional accessible semantics or description.
These are part of the component contract, not cleanup work.
Beverly can enforce required accessibility metadata at compile time, helping prevent expensive accessibility and compliance failures.
Accessible by construction, not accessible by cleanup.
Complete Example
A complete Select combines styling, binding, options, validation, and state presentation:
#![allow(unused)]
fn main() {
select()
.label(localize("user.country.label"))
.aria(localize("user.country.description"))
.bind(User::country)
.padding(12)
.radius(8)
.width(300)
.children([
option("us")
.label(localize("country.us")),
option("ca")
.label(localize("country.ca")),
option("id")
.label(localize("country.id")),
])
.error(|errors| text(errors.join("\n")))
.submitting(|| [spinner()])
}
The Select itself remains simple.
It presents choices.
The binding connects those choices to typed application state.
The Model owns validation, transformation, and state.
Select API
| API | Purpose |
|---|---|
select() | Creates a Select control |
.children([...]) | Adds available options |
.label(...) | Defines the control’s accessible name |
.aria(...) | Defines additional accessible semantics or description |
.bind(...) | Connects the Select to Model state |
.error(...) | Defines the field-error presentation |
.submitting(...) | Defines the pending-state presentation |
.on(...) | Connects Events or Commands |
.padding(...), .gap(...), .radius(...), etc. | Applies styling |
option() defines the choices inside the Select and will be covered next.
Select chooses. Binding connects. The Model owns the value.
Option
Model binding: Bind the containing
Select<T>to a model field whenTimplementsBindingValueType + Display; an individual option is a choice, not a separate value-bearing control. See Binding Controls.
Error callbacks also belong to the containing Select/RadioGroup/Dropdown, not to an individual option. See Error Callbacks.
option() represents a single available choice.
It is primarily used inside select(), but the component is intentionally designed as a simple, composable representation of a value and its presentation.
The Option represents a choice. The Select manages the choices. The Model owns the selected value.
Basic Option
An Option has a value and a label:
#![allow(unused)]
fn main() {
option("pro")
.label(localize("subscription.plan.pro.label"))
}
The value is what the application receives.
The label is what the user sees.
These do not have to be the same.
#![allow(unused)]
fn main() {
option("enterprise-plan-v2")
.label(localize("subscription.plan.enterprise.label"))
}
The application can use a stable internal value while presenting a human-readable, localized label.
Inside a Select
Options are normally composed inside a Select:
#![allow(unused)]
fn main() {
select()
.label(localize("subscription.plan.label"))
.aria(localize("subscription.plan.description"))
.bind(User::plan)
.children([
option("free")
.label(localize("subscription.plan.free.label")),
option("pro")
.label(localize("subscription.plan.pro.label")),
option("enterprise")
.label(localize("subscription.plan.enterprise.label")),
])
}
The Select determines which Option is selected.
The Option does not maintain its own copy of application state.
Value and Model State
The Option’s value is translated by the binding into the Model’s type.
For example:
#![allow(unused)]
fn main() {
#[model]
struct User {
plan: Plan,
}
}
with:
#![allow(unused)]
fn main() {
enum Plan {
Free,
Pro,
Enterprise,
}
}
The Select can present string values while the binding maps them to the appropriate application type.
The important boundary is:
Option value
↓
Select
↓
Binding
↓
Model
The Option does not need to understand the Model.
Labels
Every Option should have a user-facing label.
#![allow(unused)]
fn main() {
option("pro")
.label(localize("subscription.plan.pro.label"))
}
The label is presentation.
The value is application data.
This separation makes Options useful for localization, changing copy, accessibility, and data-driven interfaces without changing the underlying application value.
Accessibility
Options participate in the accessibility semantics of their parent choice control.
When an Option exposes user-facing content, that content should be explicitly defined rather than relying on incidental implementation details.
#![allow(unused)]
fn main() {
option("pro")
.label(localize("subscription.plan.pro.label"))
}
The parent select() still requires its own .label(...) and .aria(...) declarations:
#![allow(unused)]
fn main() {
select()
.label(localize("subscription.plan.label"))
.aria(localize("subscription.plan.description"))
.children([
option("free")
.label(localize("subscription.plan.free.label")),
option("pro")
.label(localize("subscription.plan.pro.label")),
])
}
Beverly treats accessibility as part of the component contract.
Accessible by construction, not accessible by cleanup.
Disabled Options
An Option can be unavailable without removing it from the list.
#![allow(unused)]
fn main() {
option("enterprise")
.label(localize("subscription.plan.enterprise.label"))
.disabled(true)
}
This is useful when the application wants to communicate that a choice exists but cannot currently be selected.
The reason for that state belongs to application data and logic, not the Option itself.
For example, the Model may determine that a plan is unavailable for the current account.
The View simply reflects that state.
Data-Driven Options
Options can be generated directly from Model data:
#![allow(unused)]
fn main() {
select()
.label(localize("user.country.label"))
.aria(localize("user.country.description"))
.bind(User::country)
.children(
countries.iter().map(|country| {
option(country.code())
.label(localize(country.label_key()))
})
)
}
This keeps the Option deliberately lightweight.
The application determines which choices exist.
The Option determines how each choice is represented.
Conditional Options
Options can also be composed conditionally:
#![allow(unused)]
fn main() {
select()
.label(localize("user.role.label"))
.aria(localize("user.role.description"))
.bind(User::role)
.children([
option("user")
.label(localize("role.user")),
option("admin")
.label(localize("role.admin")),
if permissions.can_manage_billing() {
option("billing")
.label(localize("role.billing"))
}
])
}
The same children() composition model applies everywhere in Beverly.
An Option is just another component that can be created, configured, and composed.
Styling
Options can expose styling hooks where the underlying choice component supports them:
#![allow(unused)]
fn main() {
option("pro")
.label(localize("subscription.plan.pro.label"))
.padding(8)
}
However, the Select generally owns the overall presentation of its option list.
This distinction keeps responsibilities clear:
- Select — selection behavior and list presentation
- Option — individual choice and its presentation
- Binding — connection to application state
- Model — value, validation, and domain rules
No Option-Level State Management
An Option should not become a miniature state-management system.
It does not need its own copy of the selected value.
It does not need its own event architecture.
It does not decide whether a choice is valid.
Those responsibilities already have a home.
The Option simply describes a choice.
If ordinary Rust and the parent component can solve it, Option does not need another abstraction.
Complete Example
#![allow(unused)]
fn main() {
select()
.label(localize("user.plan.label"))
.aria(localize("user.plan.description"))
.bind(User::plan)
.children([
option("free")
.label(localize("user.plan.free.label")),
option("pro")
.label(localize("user.plan.pro.label")),
option("enterprise")
.label(localize("user.plan.enterprise.label"))
.disabled(!permissions.can_use_enterprise()),
])
}
The structure is intentionally obvious.
The user sees a list of choices.
The Select manages the selection.
The binding connects that selection to the Model.
The Model remains the source of truth.
Option API
| API | Purpose |
|---|---|
option(value) | Creates an individual choice |
.label(...) | Defines the user-facing label |
.disabled(...) | Controls whether the choice can be selected |
| Styling methods | Controls the Option’s presentation where supported |
.children(...) | Composes additional content where supported |
Option is intentionally one of the smallest Beverly primitives.
Create. Label. Configure. Compose.
The complexity belongs in the components that actually own the behavior — not in the individual choice.
Search
Live Demo
Open the demo in a separate tab.
Search controls help users filter or locate items in a data-heavy interface. They are especially useful when a user is trying to find an item quickly without scanning a long list manually.
Validation Errors
Both Search and SearchBox use the shared error callback API:
#![allow(unused)]
fn main() {
UiElement::search()
.bind(User::query)
.error(|errors| text(errors.join("\n")))
}
Use UiElement::searchbox("Search") for the SearchBox variant. The callback
renders errors below the search control, while the accessible invalid/error
relationships remain attached to the editor. See
Error Callbacks.
Example
#![allow(unused)]
fn main() {
use bevy::prelude::*;
use beverly::prelude::*;
fn build_search(mut commands: Commands) {
commands.spawn(BeverlySearch::new("Search files")
.with_action("Submit"));
}
}
Guidance
Large interfaces should provide instant filtering feedback, clear empty states, and visible results count where possible. A search surface is most useful when it feels responsive and clear about what the query is matching.
Accessibility
Search inputs must have proper labels, preserve keyboard flow, and remain easy to operate in dense layouts. Empty-state messaging and result counts should be understandable even when a user is not relying on the visual emphasis alone.
File Input
Live Demo
Open the demo in a separate tab.
Model binding:
FileInput::new("Files").bind(User::files)connects aVec<SelectedFile>model field to the selected files and returnsUiElement. See Binding Controls.
FileInput::error(|errors| ...) renders file-selection validation errors below
the control. See Error Callbacks.
The FileInput lets a user select a file from their device.
It follows the same binding model as every other Beverly form control: the input collects a value, the binding connects it to the Model, and the Model decides what that value means.
A file input adds a few concerns that ordinary text inputs do not have:
- Where the selected file should be saved or uploaded.
- Which file types are accepted.
- What happens when a file is selected.
- How the selected file is represented in application state.
- Optional file-size and validation constraints.
- Progress and submission state for operations that process the file.
The UI component should remain simple. It provides the interface for selecting a file; the application decides what to do with it.
The File Input selects. The binding connects. The Model owns the file state.
Basic Usage
A file input can be created with file_input() and configured using the same fluent API as other Beverly inputs.
#![allow(unused)]
fn main() {
file_input()
.label(localize("document.file.label"))
.aria(localize("document.file.description"))
.placeholder(localize("document.file.placeholder"))
.bind(Document::file)
}
The important pieces are familiar:
.label(...)gives the control its accessible name..aria(...)provides additional accessible context..placeholder(...)provides guidance before a file has been selected..bind(...)connects the input to the Model.
The File Input does not need to know what the application intends to do with the file.
It might be uploaded to a server, stored locally, processed immediately, passed to an AI model, imported into a database, or written to a data lake.
That decision belongs to the application.
File State
A file is not just a string.
The Model should normally represent a selected file using an application type that contains the information the application actually needs.
For example:
#![allow(unused)]
fn main() {
struct FileSelection {
name: String,
size: u64,
mime_type: String,
}
}
The Model might then contain:
#![allow(unused)]
fn main() {
struct Document {
file: Option<FileSelection>,
}
}
The File Input binds to that state:
#![allow(unused)]
fn main() {
file_input()
.label(localize("document.file.label"))
.aria(localize("document.file.description"))
.placeholder(localize("document.file.placeholder"))
.bind(Document::file)
}
The important distinction is that the File Input does not become the owner of the file.
The Model owns the application state.
Accepted File Types
Applications can specify which file types the input accepts.
#![allow(unused)]
fn main() {
file_input()
.label(localize("document.file.label"))
.aria(localize("document.file.description"))
.placeholder(localize("document.file.placeholder"))
.accept([
"application/pdf",
"text/plain",
"text/markdown",
])
.bind(Document::file)
}
Extensions can also be useful when the application’s vocabulary is extension-based:
#![allow(unused)]
fn main() {
file_input()
.label(localize("document.file.label"))
.aria(localize("document.file.description"))
.accept([
".pdf",
".txt",
".md",
])
.bind(Document::file)
}
The accepted types describe what the UI should offer as valid input.
They are not a substitute for Model validation.
The Model should still validate the file when it receives it.
UI constraints guide input. Model validation protects application state.
This is particularly important when files can enter the application through other sources such as APIs, automated jobs, agents, or local processes.
File Location
A File Input can optionally specify where the selected file should be saved or staged.
#![allow(unused)]
fn main() {
file_input()
.label(localize("document.file.label"))
.aria(localize("document.file.description"))
.placeholder(localize("document.file.placeholder"))
.location("/documents")
.bind(Document::file)
}
The location is an application-level destination rather than part of the file’s identity.
For example:
#![allow(unused)]
fn main() {
file_input()
.location("/imports")
}
could mean:
When the user selects a file, make it available to the application’s import pipeline at
/imports.
The exact behavior of the location depends on the application.
A local desktop application might save directly to a filesystem location. Another application might use the location as a staging directory before uploading the file. An application could also map the location to object storage or another persistence layer.
The File Input provides the configuration point. The application owns the actual storage behavior.
Placeholder
The placeholder provides guidance before a file has been selected.
#![allow(unused)]
fn main() {
file_input()
.label(localize("document.file.label"))
.aria(localize("document.file.description"))
.placeholder(localize("document.file.placeholder"))
.bind(Document::file)
}
For example, the localized value might be:
Select a PDF or Markdown file
The placeholder should describe what the user can do rather than becoming the primary accessible name.
The .label(...) remains the control’s name.
Labels and Accessibility
A File Input is a form control, so accessibility is part of its basic construction.
Every File Input should have both a label and accessible description.
#![allow(unused)]
fn main() {
file_input()
.label(localize("document.file.label"))
.aria(localize("document.file.description"))
.bind(Document::file)
}
For example:
#![allow(unused)]
fn main() {
file_input()
.label(localize("document.file.label"))
.aria(localize("document.file.description"))
.placeholder(localize("document.file.placeholder"))
}
The label identifies the control.
The ARIA description can provide additional context such as supported formats, size restrictions, or what happens after selection.
For example:
Label:
Upload document
ARIA:
Select a PDF, Markdown, or text document. Maximum size 25 MB.
The important information should remain available through the control’s accessible semantics rather than relying only on visual placeholder text.
Accessible by construction, not accessible by cleanup.
File Size
File size is an important constraint for file inputs.
A UI can communicate a maximum size:
#![allow(unused)]
fn main() {
file_input()
.label(localize("document.file.label"))
.aria(localize("document.file.description"))
.max_size(25 * 1024 * 1024)
.bind(Document::file)
}
But the Model remains responsible for deciding whether the file is actually valid.
For example:
#![allow(unused)]
fn main() {
impl Document {
fn set_file(&mut self, file: FileSelection) -> Result<(), DocumentError> {
if file.size > 25 * 1024 * 1024 {
return Err(DocumentError::FileTooLarge);
}
self.file = Some(file);
Ok(())
}
}
}
The input can prevent obviously invalid selections from reaching the application, while the Model provides the authoritative validation.
This keeps the same architecture used by text inputs, selects, checkboxes, and other controls.
Binding
.bind() works the same way as other Beverly form controls.
#![allow(unused)]
fn main() {
file_input()
.label(localize("document.file.label"))
.aria(localize("document.file.description"))
.bind(Document::file)
}
Conceptually:
File Selection
↓
Binding
↓
Model Setter
↓
Application State
When a user selects a file, the binding passes the file to the Model.
The setter can then:
- validate the file type
- validate the file size
- normalize metadata
- save or stage the file
- initiate processing
- reject the file
- update application state
The File Input does not need to implement those rules itself.
File Validation
Validation belongs in the Model.
For example:
#![allow(unused)]
fn main() {
impl Document {
fn set_file(&mut self, file: FileSelection) -> Result<(), DocumentError> {
if !is_supported_type(&file.mime_type) {
return Err(DocumentError::UnsupportedFileType);
}
if file.size > MAX_FILE_SIZE {
return Err(DocumentError::FileTooLarge);
}
self.file = Some(file);
Ok(())
}
}
}
The UI can provide an accepted-type hint:
#![allow(unused)]
fn main() {
.accept([
".pdf",
".md",
".txt",
])
}
while the Model remains authoritative.
This means the same validation applies regardless of where the file came from.
A human selecting a file and an agent submitting a file should encounter the same application rules.
The UI suggests. The Model decides.
File Errors
File validation errors can be presented using the same .error(...) mechanism used by other form controls.
#![allow(unused)]
fn main() {
file_input()
.label(localize("document.file.label"))
.aria(localize("document.file.description"))
.bind(Document::file)
.error(|errors| text(errors.join("\n")))
}
The UI does not need to hard-code validation messages.
The Model produces the semantic error, and the presentation layer determines how that error is displayed and localized.
The Model returns meaning. The UI presents language.
Submitting State
File processing may take time.
For example, selecting a file might initiate:
- a local copy
- parsing
- indexing
- uploading
- virus scanning
- AI processing
- document extraction
The File Input can respond to the application’s submitting state:
#![allow(unused)]
fn main() {
file_input()
.label(localize("document.file.label"))
.aria(localize("document.file.description"))
.bind(Document::file)
.submitting(|| [spinner()])
}
The component does not need to know why the application is submitting.
It simply provides a presentation for that state.
Success and Error Presentation
The File Input can provide state-specific presentation hooks:
#![allow(unused)]
fn main() {
file_input()
.label(localize("document.file.label"))
.aria(localize("document.file.description"))
.bind(Document::file)
.submitting(|| [spinner()])
.error(|errors| text(errors.join("\n")))
.success(|| [text("Saved")])
}
These hooks describe how the component should appear when the associated state exists.
They do not create or own that state.
The application sets FormStatusState; the callback supplies supplementary
content and does not infer picker or upload state.
Multiple Files
Some applications need a single file. Others need a collection.
A File Input can support multiple selection when the application requires it:
#![allow(unused)]
fn main() {
file_input()
.label(localize("documents.files.label"))
.aria(localize("documents.files.description"))
.placeholder(localize("documents.files.placeholder"))
.multiple(true)
.accept([
".pdf",
".md",
".txt",
])
.bind(DocumentImport::files)
}
The Model can then own a collection:
#![allow(unused)]
fn main() {
struct DocumentImport {
files: Vec<FileSelection>,
}
}
The same principles apply.
The UI collects the selection.
The binding connects it.
The Model owns the resulting state.
File Processing Is Not File Input
The File Input should not become an upload framework.
For example, these are separate concerns:
File Input
↓
File Selection
↓
Model
↓
Processing / Upload / Import
Selecting a file is one operation.
Uploading it is another.
Parsing it is another.
Indexing it is another.
Sending it to an AI model is another.
Beverly’s architecture keeps those responsibilities separate.
For example:
#![allow(unused)]
fn main() {
controller! {
Document::Selected => [
Document::validate,
Document::store,
Document::index,
],
}
}
The File Input does not need to understand that pipeline.
It simply reports the user’s selection.
File Input Events
The File Input can expose UI lifecycle Events when the application needs to observe interaction.
For example:
#![allow(unused)]
fn main() {
file_input()
.label(localize("document.file.label"))
.aria(localize("document.file.description"))
.on("select", Ui::DocumentFile::Selected)
.bind(Document::file)
}
The UI Event describes what happened at the interface.
The application Command describes what should happen as a result.
This preserves the same distinction used throughout Beverly:
UI Event
↓
Controller
↓
Command
↓
Model
The File Input does not decide what selecting a file means.
Data-Driven File Inputs
File inputs can be created by ordinary Rust functions.
For example:
#![allow(unused)]
fn main() {
fn document_file_input() -> FileInput {
file_input()
.label(localize("document.file.label"))
.aria(localize("document.file.description"))
.placeholder(localize("document.file.placeholder"))
.accept([
".pdf",
".md",
".txt",
])
.max_size(25 * 1024 * 1024)
.bind(Document::file)
}
}
Then:
#![allow(unused)]
fn main() {
document_file_input()
}
This is not a second component system.
It is simply ordinary Rust returning a Beverly component.
Higher-level components are ordinary Rust functions that return lower-level components.
Styling
File Inputs use the same fluent styling model as other Beverly components.
#![allow(unused)]
fn main() {
file_input()
.label(localize("document.file.label"))
.aria(localize("document.file.description"))
.padding(12)
.radius(8)
.border_width(1)
.width(400)
}
Application-specific presentation can be built on top:
#![allow(unused)]
fn main() {
fn document_uploader() -> FileInput {
file_input()
.label(localize("document.file.label"))
.aria(localize("document.file.description"))
.placeholder(localize("document.file.placeholder"))
.accept([".pdf", ".md"])
.max_size(25 * 1024 * 1024)
.padding(16)
.radius(12)
.bind(Document::file)
}
}
The primitive remains simple while higher-level components can establish the application’s visual language.
What File Input Does Not Do
The File Input does not own:
- application state
- business rules
- file validation policy
- upload policy
- authentication
- authorization
- database state
- AI processing
- document parsing
- indexing
- application workflow
Those responsibilities belong elsewhere.
The File Input has one clear job:
Provide an accessible interface for selecting files and connect that selection to application state.
File Input and the Model
The complete relationship is straightforward:
User
↓
File Input
↓
Binding
↓
Model Setter
↓
Application State
The Model can then trigger whatever the application needs.
File Input
↓
Document::file
↓
Validation
↓
Storage
↓
Processing
↓
Indexing
↓
Application
The File Input remains unaware of the pipeline.
That separation is what makes it reusable.
File Input vs Text Input
| Text Input | File Input | |
|---|---|---|
| Primary value | Text | File / files |
.bind() | Yes | Yes |
.label() | Yes | Yes |
.aria() | Yes | Yes |
.placeholder() | Yes | Yes |
| Validation | Model | Model |
| Errors | Model → UI | Model → UI |
| Accepted types | — | Yes |
| File size | — | Yes |
| Multiple values | — | Optional |
| Processing | Application | Application |
The architecture stays the same even though the value is different.
The Principle
File Input does not need to become complicated simply because files are more complicated than strings.
The same Beverly model applies:
Create. Configure. Compose. Bind. Declare state. Emit intent.
The File Input provides the file-selection interface.
The binding connects it to the Model.
The Model validates and owns the resulting state.
The Controller decides what happens next.
And the rest of the application can process the file however it needs.
The File Input selects. The Binding connects. The Model validates. The Controller orchestrates. The application decides what the file means.
Use image() with .src(...) for local, embedded, handle-backed,
or HTTP(S) image sources. Use Photo when a fixed framed photo surface is
needed, and Avatar for circular profile imagery with initials fallback.
Images
Image
image() creates a UI image. Set its source with .src(...) and provide
accessible alternative text with .alt(...):
#![allow(unused)]
fn main() {
use beverly::prelude::*;
let report = image()
.src("photos/report.webp")
.alt("Quarterly revenue chart")
.width(640)
.height(360);
let remote = image()
.src("https://cdn.example.com/team/alex.png?size=256")
.alt("Alex Chen");
}
The same API accepts a Handle<Image> for generated or in-memory assets, local
asset paths, Bevy embedded:// paths, and external HTTP(S) URLs. An image that
conveys no information should use .decorative(true) instead of an empty
alternative description.
Local SVG Assets
The package includes small SVG samples at assets/examples/analytics.svg and
assets/examples/mark.svg. In a Bevy application whose asset root is the
workspace assets directory, refer to these as relative paths:
#![allow(unused)]
fn main() {
image()
.src("examples/analytics.svg")
.alt("Line chart showing monthly usage increasing through the year")
.width(640)
.height(360)
image()
.src("examples/mark.svg")
.alt("Geometric Beverly mark")
.width(96)
.height(96)
}
SVG is rasterized at its intrinsic size. External SVG resources are disabled; inline vector shapes and paths work, but linked images, external styles, and external fonts are not fetched.
Supported Formats
The built-in decoder supports PNG, JPEG, GIF, WebP, SVG, BMP, and ICO. These
formats are enabled by Beverly’s default image_formats feature plus SVG
rasterization. AVIF is unsupported by the configured Bevy decoder and produces
an explicit load error. Animated GIF/WebP sources display as a decoded still
image.
Encoded downloads are limited to 16 MiB and raster output to 16 megapixels. SVG output is subject to the same pixel limit. These limits reduce accidental large allocations; they are not a complete defense against untrusted assets.
External images use Bevy’s HTTP(S) asset readers. Browser servers must permit the app’s origin with CORS, and HTTPS apps require HTTPS image URLs to avoid mixed-content blocking. Query parameters are preserved, fragments are removed, and embedded credentials or non-HTTP(S) URL schemes are rejected. Apply application-specific URL allowlists when sources are not trusted.
Loading State
ImageStatus on the spawned image entity reports Empty, Loading, Ready,
or Failed(message). Observe it to show a placeholder, retain a fallback, or
hide a decorative image while loading. Changing .src(...) loads the new
source and replaces the existing image. Photo::new(Handle<Image>) and avatar
image paths remain available for their specialized layouts.
Accessibility
Provide meaningful alternative text for informative images:
#![allow(unused)]
fn main() {
image()
.src("products/compass.webp")
.alt("Brass pocket compass with its needle pointing north")
}
For decorative imagery, explicitly mark it:
#![allow(unused)]
fn main() {
image()
.src("textures/paper-grain.svg")
.decorative(true)
}
A missing or whitespace-only alt on a non-decorative image is reported as a
warning when spawned. Alternative text should describe the information the
image contributes, not repeat nearby captions.
Live Demo
Open the demo in a separate tab.
Avatar
Live Demo
Open the demo in a separate tab.
An Avatar is a circular image used to represent a person, account, or other identity.
It is intentionally a very small abstraction over the Image primitive. It does not introduce a new image system, state system, or composition model.
An Avatar is an Image with an opinionated shape.
Basic Usage
Create an Avatar with avatar() and configure it with the same fluent API used by other Beverly components.
#![allow(unused)]
fn main() {
avatar()
.src("profile.jpg")
}
The default presentation is circular.
For accessibility, provide a description of who or what the image represents:
#![allow(unused)]
fn main() {
avatar()
.src("profile.jpg")
.aria(localize("user.avatar.description"))
}
The Avatar does not require application state. It simply presents an image.
Sizing
Use the same sizing primitives available to Image:
#![allow(unused)]
fn main() {
avatar()
.src("profile.jpg")
.width(48)
.height(48)
}
Because an Avatar is circular, width and height are normally equal.
Different sizes can be used for different contexts:
#![allow(unused)]
fn main() {
avatar()
.src("profile.jpg")
.width(32)
.height(32)
}
#![allow(unused)]
fn main() {
avatar()
.src("profile.jpg")
.width(64)
.height(64)
}
#![allow(unused)]
fn main() {
avatar()
.src("profile.jpg")
.width(96)
.height(96)
}
Styling
Avatar provides a convenient circular presentation without requiring the application to manually configure the image radius.
Additional styling can still be applied through the normal fluent API:
#![allow(unused)]
fn main() {
avatar()
.src("profile.jpg")
.width(48)
.height(48)
.border_width(2)
}
The Avatar remains an Image underneath. Its purpose is simply to establish a useful default presentation for identity images.
Data-Driven Avatars
Avatars commonly come from application data.
#![allow(unused)]
fn main() {
avatar()
.src(user.avatar_url())
.aria(localize("user.avatar.description"))
}
The Avatar does not care where the image came from.
The Model might provide the URL from a database, API, local file, or another source. The View simply presents it.
Model → View → Avatar → Image
The Avatar does not own the user’s identity. It presents it.
Composition
Avatar composes with other Beverly primitives like any other component.
#![allow(unused)]
fn main() {
card()
.children([
avatar()
.src(user.avatar_url())
.aria(localize("user.avatar.description")),
text(user.name()),
badge(user.status()),
])
}
It can also be used anywhere an Image can be used:
#![allow(unused)]
fn main() {
row()
.children([
avatar().src("alice.jpg"),
text("Alice"),
])
}
There is no special Avatar composition system.
One composition model. Ordinary Rust.
Higher-Level Components
Building a higher-level Avatar component is just an ordinary Rust function that returns an Avatar.
#![allow(unused)]
fn main() {
fn user_avatar(user: &User) -> Avatar {
avatar()
.src(user.avatar_url())
.width(48)
.height(48)
.aria(localize("user.avatar.description"))
}
}
Use it like any other component:
#![allow(unused)]
fn main() {
user_avatar(&user)
}
A more specialized component can build on the same primitive:
#![allow(unused)]
fn main() {
fn profile_avatar(user: &User) -> Avatar {
avatar()
.src(user.avatar_url())
.width(64)
.height(64)
.aria(localize("user.profile_avatar.description"))
}
}
There is no need for a nested Avatar component or a special extension mechanism.
Higher-level components are ordinary Rust functions that return lower-level components.
Interaction
An Avatar is not inherently interactive.
If an application wants an Avatar to perform an action, the application can attach the appropriate UI event:
#![allow(unused)]
fn main() {
avatar()
.src(user.avatar_url())
.aria(localize("user.avatar.description"))
.on("click", Command::User::OpenProfile)
}
The Avatar reports the interaction. The application decides what that interaction means.
An Avatar can also be placed inside another interactive component when that better represents the interface.
Accessibility
An Avatar is an image, so its accessible meaning should describe the identity or purpose represented by the image.
#![allow(unused)]
fn main() {
avatar()
.src(user.avatar_url())
.aria(localize("user.avatar.description"))
}
For example, the localized description might communicate that the image represents a particular user.
If the Avatar is interactive, its surrounding interaction should also have an appropriate accessible label and description.
Accessible by construction, not accessible by cleanup.
What Avatar Does Not Do
Avatar does not:
- own user state
- manage identity
- load or cache application data
- manage authentication
- provide a separate image system
- define application behavior
- create application Events
- replace the Image primitive
Those concerns belong elsewhere.
The Avatar only provides a convenient semantic presentation for a circular image.
Avatar and Image
| Component | Purpose |
|---|---|
image() | General-purpose image presentation |
avatar() | Circular image used for identity or representation |
When you need a general image, use image().
When you need a circular identity image, use avatar().
Internally, the distinction should remain small.
The Avatar is simply an Image with an opinionated presentation.
Design Principle
Beverly should not create a new abstraction unless the abstraction carries useful meaning.
Image already provides the underlying image behavior. Avatar adds a common semantic pattern: a circular image representing identity.
That makes the abstraction useful without making it complicated.
Create. Configure. Compose. Extend with ordinary Rust.
The framework provides the primitive. Your application provides the identity.
Icons
Live Demo
Open the demo in a separate tab.
Beverly includes the fabulous Feather icons: a clean, open-source set of outline icons designed by Cole Bemis and the Feather contributors. Feather is distributed under the MIT license.
The bundled icons are SVG vector assets, not bitmap images or an icon font. They scale cleanly and can be recolored and animated. Beverly embeds them at compile time, so applications do not need to copy an icon assets folder.
Basic Usage
BeverlyPlugin installs the icon system. Import IconCommands to spawn icons inside a UI parent:
#![allow(unused)]
fn main() {
use beverly::icons::IconCommands;
use bevy::prelude::*;
fn add_icons(parent: &mut ChildSpawnerCommands) {
parent.spawn_feather("coffee");
parent.spawn_feather_sized("heart", 32.0);
parent.spawn_feather_muted("info", 20.0);
parent.spawn_feather_colored("check", 24.0, Color::srgb(0.2, 0.7, 0.4));
}
}
Names match the bundled SVG filenames without .svg, such as arrow-left, check-circle, and alert-triangle. The default size is 24 pixels.
The regular and sized helpers follow the theme’s text color; muted helpers follow its muted text color. Explicitly colored icons retain their supplied color when the theme changes.
Light/Dark Circle
The additional theme-toggle SVG is Beverly’s shadcn/ui-style light/dark circle icon, used by the page’s theme-switch button. It is bundled alongside Feather icons but is not part of the original Feather set. shadcn/ui is the styling reference, not a separate SVG icon pack.
#![allow(unused)]
fn main() {
use beverly::icons::IconCommands;
use bevy::prelude::*;
fn add_theme_icon(parent: &mut ChildSpawnerCommands) {
parent.spawn_feather_sized("theme-toggle", 24.0);
}
}
This spawns the visual icon only. To make it switch the theme, use the Theme Toggle component or wire toggle_theme to your own button.
Button Icons
ButtonChild::icon uses the same embedded icon names:
#![allow(unused)]
fn main() {
use beverly::components::button::{BeverlyButton, ButtonChild};
use bevy::prelude::*;
fn add_save_button(parent: &mut ChildSpawnerCommands) {
parent.spawn(
BeverlyButton::standard("Save")
.children([
ButtonChild::icon("save"),
ButtonChild::text("Save"),
]),
);
}
}
Give icon-only controls an accessible name through the owning control’s semantics. A decorative icon does not replace a button’s accessible label.
Animation
SVG icons are animatable through Beverly’s Bevy components. For example, the loader icon automatically spins, and its decorative rotation stops when reduced motion is enabled:
#![allow(unused)]
fn main() {
use beverly::icons::IconCommands;
use bevy::prelude::*;
fn add_loading_icon(parent: &mut ChildSpawnerCommands) {
parent.spawn_feather_sized("loader", 24.0);
}
}
For a custom size animation, update IconNode::size and the UI slot’s dimensions together:
#![allow(unused)]
fn main() {
use beverly::icons::{IconCommands, IconNode};
use beverly::theme::AccessibilityVisualPolicyResource;
use bevy::prelude::*;
#[derive(Component)]
struct PulsingIcon;
fn add_pulsing_icon(parent: &mut ChildSpawnerCommands) {
let icon = parent.spawn_feather_sized("heart", 24.0);
parent.commands().entity(icon).insert(PulsingIcon);
}
fn pulse_icons(
time: Res<Time>,
policy: Res<AccessibilityVisualPolicyResource>,
mut icons: Query<(&mut IconNode, &mut Node), With<PulsingIcon>>,
) {
let size = if policy.current.reduced_motion {
24.0
} else {
24.0 + 2.0 * time.elapsed_secs().sin()
};
for (mut icon, mut node) in &mut icons {
icon.size = size;
node.width = Val::Px(size);
node.height = Val::Px(size);
}
}
}
Register pulse_icons in Update. Reserve a stable outer slot if the changing size should not move neighboring content. SVG rendering is handled by bevy_svg; arbitrary SVG/CSS animation declarations are not executed by the icon system.
Gallery
The icon gallery includes every bundled icon, including the light/dark circle, with names and the shared theme toggle:
cargo run --example icons
cargo run --example icons -- dark
Browse the original Feather catalog for designs and names, and the Feather repository for upstream SVGs and attribution.
Navigation
Beverly provides explicit Page, Layout, and Link primitives for in-app routing, alongside reusable navigation surfaces such as Navbar, Sidebar, and NavButton.
See Pages, Links, and Layouts for route registration and page outlets. The Navigation Guide covers navigation surfaces, app-owned NavButton state, and page transitions.
Pages, Links, and Layouts
Beverly keeps route architecture explicit while letting each page and layout remain an ordinary composable UI tree.
- A
Pagenames a route and its content. - A
Layoutdescribes the reusable shell around a page. - A
Linkpoints to a route pattern.
Register Pages
Return pages from named functions and register them together on the root Ui:
#![allow(unused)]
fn main() {
use beverly::prelude::*;
fn application_ui() -> Ui {
ui().pages([
home_page(),
users_page(),
user_page(),
])
}
fn home_page() -> Page {
page()
.route("/")
.layout(app_layout())
.children([text("Home")])
}
fn users_page() -> Page {
page()
.route("/users")
.layout(app_layout())
.children([text("User list")])
}
fn user_page() -> Page {
page()
.route("/users/:id")
.layout(app_layout())
.children([text("User profile")])
}
}
The first registered route matching / is shown at startup. If no root route exists, Beverly starts with the first registered page. Route patterns match path segments exactly; a segment beginning with : captures one value.
Compose a Layout
Layouts use the same composable elements as pages. page_outlet() marks the location where the active page’s content is rendered:
#![allow(unused)]
fn main() {
fn app_layout() -> Layout {
layout().children([
text("Application header"),
row()
.gap(Val::Px(16.0))
.children([
text("Sidebar"),
page_outlet(),
]),
text("Application footer"),
])
}
}
A page without .layout(...) renders directly in the route surface. Pages using the same layout share its shell; changing the layout does not change their routes or content.
Navigate with Links
The visible string passed to link() is its label. Accessibility description and destination are separate:
#![allow(unused)]
fn main() {
fn user_link(user_id: u64) -> UiElement {
link("View user")
.aria("Opens this user's profile")
.to("/users/:id")
.params([("id", user_id)])
}
}
When activated, a link with a target navigates only if the resolved path matches a registered page. A link without .to(...) still emits LinkClicked and can be handled by application code. Disabled links do not activate.
Query strings can be included in the target and are retained when path parameters are substituted:
#![allow(unused)]
fn main() {
link("View activity")
.aria("Opens this user's activity")
.to("/users/:id?tab=activity&search=red+fox")
.params([("id", user_id)])
}
The current path, path parameters, and parsed query are available globally through Res<RouteState>. Beverly also attaches a RouteContext component to every entity in the active page subtree, including layout elements, page content, and descendants created later. Component systems can read it with Query<&RouteContext>; custom context-aware builders can read the same snapshot through UiBuildContext::route_context() while constructing their entity.
#![allow(unused)]
fn main() {
fn inspect_route(route: Res<RouteState>) {
info!("path: {}, id: {:?}, tab: {:?}", route.path, route.params.get("id"), route.query.get("tab"));
}
fn inspect_routed_widget(contexts: Query<&RouteContext>) {
for context in &contexts {
info!("id: {:?}, tab: {:?}", context.param("id"), context.query_value("tab"));
}
}
}
RouteContext exposes path, params, and query maps, with param(name) and query_value(name) convenience accessors. Query keys and values are form-decoded (+ becomes a space); repeated query keys retain the last value. The same snapshot can be read during fluent construction:
#![allow(unused)]
fn main() {
fn user_header(context: &mut UiBuildContext<'_>) -> Entity {
let user_id = context
.route_context()
.and_then(|route| route.param("id").map(str::to_owned))
.unwrap_or_default();
context.world.spawn((Text::new(format!("User {user_id}")), ChildOf(context.parent))).id()
}
}
Routing is internal to the Bevy application and does not synchronize a browser URL. Applications that need custom state transitions can continue to use NavButton and handle its interaction in their own systems.
Navigation
Beverly supports two complementary navigation styles. Page, Layout, and routed Link provide a small route registry for applications that want named pages and reusable shells. NavButton remains an app-owned interaction primitive for applications that manage page state themselves.
The navigation pieces are:
NavbarandNavbarSectionsfor a three-section top bar.SidebarandSidebarStatefor a collapsible side navigation surface.NavButtonandPageIdfor page buttons used by the sidebar.
Use
PageandLayoutfor explicit routes. UseNavButtonwhen the application owns page state.
Routed Pages
For the built-in route registry and page outlet API, see Pages, Links, and Layouts. Pages are registered explicitly with ui().pages([...]); dynamic path segments use :name, and links can navigate to a matching route.
RouteState exposes the active path, captured path parameters, and parsed query map as a resource. Each entity in the active page subtree also has a RouteContext component, queryable by child systems; context-aware builders can read it through UiBuildContext::route_context(). Query values are form-decoded. Routing is internal and does not synchronize with a browser address bar.
Navigation Flow
nav_button(...) creates a Bevy Button with a NavButton { page } component. When the user clicks or activates it, Bevy changes the button’s Interaction. A system can observe that change and route to the selected page:
Pointer or keyboard activation
↓
Interaction becomes Pressed
↓
Query matches Changed<Interaction> + NavButton
↓
Application selects NavButton.page
This is an interaction-triggered system, not a Beverly navigation message. The NavButton does not publish a custom Bevy Message, change a Bevy State, or spawn page content for you.
#![allow(unused)]
fn main() {
use bevy::prelude::*;
use beverly::components::nav_button::{NavButton, PageId};
#[derive(States, Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
enum AppPage {
#[default]
Music,
Settings,
}
fn route_from_nav_button(
buttons: Query<(&Interaction, &NavButton), Changed<Interaction>>,
mut next_page: ResMut<NextState<AppPage>>,
) {
for (interaction, button) in &buttons {
if *interaction == Interaction::Pressed {
let page = match button.page {
PageId::Settings => AppPage::Settings,
_ => AppPage::Music,
};
next_page.set(page);
}
}
}
}
Register the app state and handler in your own plugin. BeverlyPlugin installs the visual and sidebar systems; the application still decides what each PageId means.
Sidebar
spawn_sidebar builds a sidebar with a drawer toggle and buttons for PageId::all(). It takes a parent child-spawner, the UiFonts resource, and a top offset in pixels:
#![allow(unused)]
fn main() {
use bevy::prelude::*;
use beverly::components::sidebar::spawn_sidebar;
use beverly::primitives::root::UiFonts;
fn build_sidebar(parent: &mut ChildSpawnerCommands, ui_fonts: &UiFonts) {
spawn_sidebar(parent, ui_fonts, 64.0);
}
}
The offset is useful when the sidebar sits below a fixed top bar. SidebarPlugin, included by BeverlyPlugin, initializes SidebarState, handles the drawer toggle, animates the sidebar width, and updates its theme colors.
SidebarState is a resource with a public open: bool field. It defaults to open. The built-in drawer button toggles this value; the sidebar system animates between expanded and collapsed widths and hides text labels in the collapsed layout.
Page Buttons
nav_button(parent, page, ui_fonts) creates one sidebar button. drawer_toggle(parent, ui_fonts) creates the Menu control that toggles SidebarState:
#![allow(unused)]
fn main() {
use bevy::prelude::*;
use beverly::components::nav_button::{PageId, drawer_toggle, nav_button};
use beverly::primitives::root::UiFonts;
fn add_custom_sidebar_items(parent: &mut ChildSpawnerCommands, ui_fonts: &UiFonts) {
drawer_toggle(parent, ui_fonts);
nav_button(parent, PageId::Settings, ui_fonts);
}
}
PageId is the page metadata carried by a navigation button. Its methods are:
PageId::all()returns the seven pages used in the default sidebar.PlaylistDetailis intentionally not in that list, but can still be used to create a button directly.page.label()returns the display label.page.body()returns sample descriptive text for that page.page.icon()returns the associated Feather icon.page.accent()returns the page’s example accent color.
The default button list is only a starting point. To render a custom set or order, call nav_button for the PageId values your app wants to show.
Navbar
spawn_navbar(commands, fixed) creates a full-width, three-column navbar and returns its entity. It also inserts NavbarSections on that entity, containing the child entity IDs for the left, center, and right sections.
#![allow(unused)]
fn main() {
use bevy::prelude::*;
use beverly::components::navbar::{
NavbarSections, add_to_center, add_to_left, add_to_right, spawn_navbar,
};
fn build_top_bar(mut commands: Commands) {
spawn_navbar(&mut commands, true);
}
fn fill_top_bar(
mut commands: Commands,
new_navbars: Query<&NavbarSections, Added<NavbarSections>>,
) {
for sections in &new_navbars {
add_to_left(&mut commands, sections, text("Workspace"));
add_to_center(&mut commands, sections, text("Library"));
add_to_right(&mut commands, sections, text("Account"));
}
}
}
Run build_top_bar during Startup and fill_top_bar during Update. The section query runs after the navbar and its children have been created, and Added<NavbarSections> prevents adding the sample content repeatedly.
Use true for a fixed navbar positioned at the top of its parent, or false for a relative navbar in normal layout flow. NavbarPlugin, included by BeverlyPlugin, applies that positioning when the Navbar component changes.
The Navbar builder methods are:
Navbar::new()creates a non-fixed navbar..fixed(bool)sets whether the navbar uses fixed positioning.
NavbarBundle::new(fixed) constructs the navbar bundle directly. Most applications should use spawn_navbar, which also creates the three section entities and records their IDs.
The section helpers all take &mut Commands, &NavbarSections, and any Bevy Bundle; each returns the spawned child entity:
add_to_left(...)places a bundle in the left section.add_to_center(...)places a bundle in the center section.add_to_right(...)places a bundle in the right section.
Enter and Exit Transitions
Page transitions belong to your page lifecycle, not to NavButton. Beverly’s UiAnimationPlugin (installed by BeverlyPlugin) can animate a page root with TransformTransitionTarget. For a Bevy UI Node, the target’s X/Y translation is measured in logical pixels.
Start an entering page slightly offset, then target its resting transform:
#![allow(unused)]
fn main() {
use std::time::Duration;
use bevy::prelude::*;
use beverly::animation::animation::{Easing, TransformTransitionTarget, Transition};
fn spawn_page(commands: &mut Commands) -> Entity {
let transition = Transition::new(Duration::from_millis(220), Easing::EaseOut);
commands
.spawn((
Node::default(),
UiTransform::from_translation(Val2::px(24.0, 0.0)),
TransformTransitionTarget::new(Transform::IDENTITY, transition),
))
.id()
}
}
For an exit, set the current page root’s target to a small offset and let the animation run:
#![allow(unused)]
fn main() {
fn begin_page_exit(commands: &mut Commands, page_root: Entity) {
let transition = Transition::new(Duration::from_millis(180), Easing::EaseIn);
commands.entity(page_root).insert(TransformTransitionTarget::new(
Transform::from_translation(Vec3::new(-24.0, 0.0, 0.0)),
transition,
));
}
}
Do not despawn the outgoing root immediately when starting its exit animation. Keep it mounted until the transition duration has elapsed, then remove it and finish the route change. A common lifecycle is to mark the old page as exiting, start its target animation, and let a timer or page-transition coordinator despawn it after the transition. Spawn the new page with its own entering target. When using Bevy OnExit systems, avoid automatic immediate despawn of an entity that still needs to animate.
Transitions are decorative by default, so Beverly’s reduced-motion policy can make them immediate. Keep page identity and focus behavior correct without relying on the motion to communicate the route change.
Accessibility
Use clear page labels, preserve a logical keyboard order, and make the current route visually apparent in your app’s page-button styling. Navigation controls are actions for changing context, so the application should also move focus to an appropriate location when a new page is shown. Keep the route change understandable when motion is disabled.
Navbar
Live Demo
Open the demo in a separate tab.
A Navbar is a horizontal layout primitive for organizing content across the top or edge of an interface.
It provides three named sections:
leftcenterright
The center section is always centered within the Navbar. The left and right sections work inward from their respective edges.
The Navbar can also contain arbitrary children(), making it useful for simple layouts as well as more structured application navigation.
The Navbar provides the layout. The application provides the navigation.
Basic Usage
Create a Navbar with navbar() and compose content into its sections:
#![allow(unused)]
fn main() {
navbar()
.left([
text("My App"),
])
.center([
text("Dashboard"),
])
.right([
text("Account"),
])
}
The three sections have predictable spatial behavior:
┌──────────────────────────────────────────────────────────┐
│ LEFT CENTER RIGHT │
│ ←─────── ─────── ───────→ │
└──────────────────────────────────────────────────────────┘
The center section remains centered in the Navbar rather than simply appearing after the left section.
This makes the layout predictable even when the left and right sections contain different amounts of content.
Named Sections
The three named sections are semantic conveniences for the most common Navbar layout.
Left
left() places content against the left side of the Navbar.
#![allow(unused)]
fn main() {
navbar()
.left([
button().text(localize("navigation.menu")),
text("My App"),
])
}
The left section grows inward from the edge toward the center.
Center
center() places content in the center of the Navbar.
#![allow(unused)]
fn main() {
navbar()
.center([
text("Dashboard"),
])
}
The center section is centered relative to the Navbar itself.
It does not simply follow the left section.
This distinction is important when building predictable application layouts.
Right
right() places content against the right side of the Navbar.
#![allow(unused)]
fn main() {
navbar()
.right([
button().text(localize("user.profile")),
])
}
The right section grows inward from the right edge toward the center.
Using All Three
A common application Navbar might look like this:
#![allow(unused)]
fn main() {
navbar()
.left([
button().text(localize("navigation.menu")),
text("Beverly"),
])
.center([
text("Dashboard"),
])
.right([
button().text(localize("user.profile")),
])
}
The resulting structure is conceptually:
LEFT CONTENT CENTER CONTENT RIGHT CONTENT
→ ● ←
The center remains centered even as the left and right content changes.
Children
children() remains the general composition primitive.
#![allow(unused)]
fn main() {
navbar()
.children([
text("Beverly"),
text("Dashboard"),
])
}
This is useful when the application does not need the three-section layout.
Named sections are conveniences, not a replacement for children().
A Navbar can therefore be used as either a simple container:
#![allow(unused)]
fn main() {
navbar()
.children([
text("My Application"),
])
}
or as a structured three-section layout:
#![allow(unused)]
fn main() {
navbar()
.left(text("Beverly"))
.center(text("Dashboard"))
.right(text("Account"))
}
A section can accept one child or many children.
#![allow(unused)]
fn main() {
navbar()
.left(text("Beverly"))
}
#![allow(unused)]
fn main() {
navbar()
.left([
icon("menu"),
text("Beverly"),
])
}
children() is the general primitive. Named sections add useful semantics.
Fixed and Non-Fixed Navbars
A Navbar can either participate in normal layout flow or remain fixed relative to its containing interface.
For a normal Navbar:
#![allow(unused)]
fn main() {
navbar()
.left(text("Beverly"))
.center(text("Dashboard"))
.right(text("Account"))
}
For a fixed Navbar:
#![allow(unused)]
fn main() {
navbar()
.fixed(true)
.left(text("Beverly"))
.center(text("Dashboard"))
.right(text("Account"))
}
A fixed Navbar remains positioned according to the application’s layout rules rather than moving with ordinary content flow.
The Navbar itself does not decide whether an application should use fixed positioning. It simply provides the layout option.
Styling
Navbar uses the same fluent styling model as other Beverly components.
#![allow(unused)]
fn main() {
navbar()
.padding(16)
.height(64)
.radius(12)
.left(text("Beverly"))
.center(text("Dashboard"))
.right(text("Account"))
}
Layout and presentation remain separate from application behavior.
The Navbar determines where content goes. Its children determine what that content is.
Navigation
Despite its name, the Navbar does not own application navigation.
Navigation is application behavior.
A navigation control can emit a UI Event or connect directly to an application Command:
#![allow(unused)]
fn main() {
navbar()
.left([
button().text(localize("navigation.home"))
.on("click", Command::Navigation::Home),
button().text(localize("navigation.settings"))
.on("click", Command::Navigation::Settings),
])
}
The Navbar does not need to know what Command::Navigation::Home does.
It only provides the surface in which the controls are arranged.
The Navbar provides the layout. Buttons provide the interaction. Commands provide the application behavior.
Responsive Content
Because the Navbar is composed from ordinary components, its contents can be data-driven.
#![allow(unused)]
fn main() {
navbar()
.left(navigation_menu())
.center(text(page.title()))
.right(user_actions())
}
Higher-level pieces are ordinary Rust functions:
#![allow(unused)]
fn main() {
fn user_actions() -> impl Component {
row()
.children([
button().text(localize("user.notifications")),
avatar()
.src(user.avatar_url())
.aria(localize("user.avatar.description")),
])
}
}
There is no separate Navbar component language.
Higher-level components are ordinary Rust functions that return lower-level components.
Accessibility
The Navbar is a layout primitive. Its children remain responsible for their own interaction semantics.
Interactive elements inside the Navbar should have their own accessible labels and descriptions:
#![allow(unused)]
fn main() {
navbar()
.left([
button().text(localize("navigation.menu")),
])
.center([
text("Dashboard"),
])
}
The Navbar itself can also expose accessible semantics when the application needs the navigation surface to be identified:
#![allow(unused)]
fn main() {
navbar()
.label(localize("navigation.primary.label"))
.aria(localize("navigation.primary.description"))
.left([
text("Beverly"),
])
.center([
text("Dashboard"),
])
}
The exact semantic role belongs to the Navbar’s implementation and context; applications should not need to manually reproduce the underlying layout or accessibility mechanics.
Accessible by construction, not accessible by cleanup.
What Navbar Does Not Do
Navbar does not:
- own navigation state
- decide which page is active
- manage routing
- own application data
- define authentication
- define user permissions
- replace Buttons or Links
- require a separate navigation state system
Those concerns belong to the application.
The Navbar is responsible for organizing interface content.
Design Principle
The Navbar deliberately provides only the structure that is difficult to express consistently by hand:
- content from the left edge
- content from the center
- content from the right edge
- predictable centered positioning
- optional fixed positioning
- general
children()composition
Everything else remains ordinary Beverly composition and ordinary Rust.
Three sections when you need them. children() when you don’t.
The center stays centered. The edges work inward. The application owns the meaning.
Sidebar
Live Demo
Open the demo in a separate tab.
Header, Body, and Footer
Use UiElement::sidebar(top_offset_px) for the concrete fluent Sidebar API:
#![allow(unused)]
fn main() {
UiElement::sidebar(0.0)
.header(text("Workspace"))
.body(text("Navigation details"))
.footer(text("Signed in locally"))
}
.header(...), .body(...), and .footer(...) place content at the top,
middle, and bottom of the Sidebar panel, regardless of builder-call order.
Each accepts a single element or an iterable; repeated calls append content.
The existing drawer toggle and navigation buttons remain inside the body,
before custom body content. The body grows into available height, keeping the
footer at the bottom. ContentSection markers identify each region. Supply
the UiFonts resource when building a Sidebar in a custom application.
A Sidebar is a layout primitive for organizing content along the side of an interface.
A Sidebar can be positioned on either the left or right side of its containing layout.
Like the Navbar and Footer, it supports general children() composition and does not impose a separate application structure.
The Sidebar provides the layout. The application provides the content and behavior.
Basic Usage
Create a Sidebar with sidebar() and place it on the left:
#![allow(unused)]
fn main() {
sidebar()
.side(Side::Left)
.children([
text("Navigation"),
text("Dashboard"),
text("Settings"),
])
}
Or place it on the right:
#![allow(unused)]
fn main() {
sidebar()
.side(Side::Right)
.children([
text("Details"),
text("Properties"),
])
}
The side is an explicit configuration choice:
#![allow(unused)]
fn main() {
sidebar()
.side(Side::Left)
}
#![allow(unused)]
fn main() {
sidebar()
.side(Side::Right)
}
One component. Two sides. Explicit configuration.
Children
children() is the fundamental composition primitive.
#![allow(unused)]
fn main() {
sidebar()
.side(Side::Left)
.children([
text("Dashboard"),
text("Projects"),
text("Settings"),
])
}
A Sidebar can contain one child or many:
#![allow(unused)]
fn main() {
sidebar()
.side(Side::Left)
.children(text("Navigation"))
}
#![allow(unused)]
fn main() {
sidebar()
.side(Side::Left)
.children([
icon("home"),
text("Dashboard"),
text("Projects"),
text("Settings"),
])
}
There is no special Sidebar composition system.
children() is the general primitive.
Left and Right Positioning
The Sidebar works from the edge of the containing layout inward.
A left Sidebar:
┌──────────────┬──────────────────────────────────────────┐
│ │ │
│ SIDEBAR │ MAIN CONTENT │
│ ← │ │
│ │ │
└──────────────┴──────────────────────────────────────────┘
A right Sidebar:
┌──────────────────────────────────────────┬──────────────┐
│ │ │
│ MAIN CONTENT │ SIDEBAR → │
│ │ │
│ │ │
└──────────────────────────────────────────┴──────────────┘
The application does not need separate components for these two layouts.
#![allow(unused)]
fn main() {
sidebar()
.side(Side::Left)
}
and:
#![allow(unused)]
fn main() {
sidebar()
.side(Side::Right)
}
Fixed and Non-Fixed Sidebars
A Sidebar can either participate in normal layout flow or remain fixed relative to its containing interface.
A normal Sidebar:
#![allow(unused)]
fn main() {
sidebar()
.side(Side::Left)
.children(navigation_menu())
}
A fixed Sidebar:
#![allow(unused)]
fn main() {
sidebar()
.side(Side::Left)
.fixed(true)
.children(navigation_menu())
}
The same applies to a right Sidebar:
#![allow(unused)]
fn main() {
sidebar()
.side(Side::Right)
.fixed(true)
.children(details_panel())
}
The Sidebar provides the positioning option. The application decides whether the Sidebar should be fixed.
Styling
Sidebar uses the same fluent styling model as other Beverly components.
#![allow(unused)]
fn main() {
sidebar()
.side(Side::Left)
.width(240)
.padding(16)
.children(navigation_menu())
}
A right Sidebar can use the same API:
#![allow(unused)]
fn main() {
sidebar()
.side(Side::Right)
.width(320)
.padding(16)
.children(details_panel())
}
The side does not change the composition model.
Interactive Content
A Sidebar is not inherently interactive.
It commonly contains interactive components such as Buttons or other navigation controls:
#![allow(unused)]
fn main() {
sidebar()
.side(Side::Left)
.children([
button().text(localize("navigation.home"))
.on("click", Command::Navigation::Home),
button().text(localize("navigation.settings"))
.on("click", Command::Navigation::Settings),
])
}
The Sidebar does not need to know what those commands mean.
It simply provides the space in which the controls are arranged.
The Sidebar provides the layout. The children provide the interaction. The application provides the behavior.
Data-Driven Sidebars
Sidebar content can come directly from application data:
#![allow(unused)]
fn main() {
sidebar()
.side(Side::Left)
.children(
navigation_items()
)
}
A higher-level component can be an ordinary Rust function:
#![allow(unused)]
fn main() {
fn navigation_items() -> impl Component {
column()
.children([
navigation_item("Dashboard", Command::Navigation::Dashboard),
navigation_item("Projects", Command::Navigation::Projects),
navigation_item("Settings", Command::Navigation::Settings),
])
}
}
The Sidebar does not need to know how those items are generated.
Higher-level components are ordinary Rust functions that return lower-level components.
Accessibility
A Sidebar can expose accessible semantics when it represents a meaningful region of the interface:
#![allow(unused)]
fn main() {
sidebar()
.side(Side::Left)
.label(localize("navigation.sidebar.label"))
.aria(localize("navigation.sidebar.description"))
.children(navigation_menu())
}
Interactive elements inside the Sidebar remain responsible for their own accessible labels and descriptions:
#![allow(unused)]
fn main() {
sidebar()
.side(Side::Left)
.children([
button().text(localize("navigation.home"))
.on("click", Command::Navigation::Home),
])
}
The Sidebar should provide the appropriate structural semantics without requiring the application to manually reproduce the underlying accessibility behavior.
Accessible by construction, not accessible by cleanup.
Sidebar as Navigation
A Sidebar is often used for navigation, but navigation is not its responsibility.
The same Sidebar can contain navigation:
#![allow(unused)]
fn main() {
sidebar()
.side(Side::Left)
.children(navigation_menu())
}
or application information:
#![allow(unused)]
fn main() {
sidebar()
.side(Side::Right)
.children([
text("Document Details"),
text(document.title()),
badge(document.status()),
])
}
or tools:
#![allow(unused)]
fn main() {
sidebar()
.side(Side::Right)
.children(tool_panel())
}
The layout primitive stays the same.
A Sidebar does not have to be navigation. It is simply content positioned at the side of an interface.
What Sidebar Does Not Do
Sidebar does not:
- own navigation state
- manage routing
- decide which page is active
- own application data
- define permissions
- define authentication
- require a separate navigation system
- dictate what content belongs inside it
Those concerns belong to the application.
The Sidebar is responsible for positioning and composing side content.
Design Principle
The Sidebar deliberately provides only the structure applications repeatedly need:
- left or right positioning
- predictable edge alignment
- optional fixed positioning
- general
children()composition - fluent styling
Everything else remains ordinary Beverly composition and ordinary Rust.
Left or right when you need it. children() when you don’t need anything more.
The Sidebar provides the space. The application gives the space meaning.
Footer
Live Demo
Open the demo in a separate tab.
The standalone Footer component is created with footer(...). This is
different from the .footer(...) content-section helper available on Card,
Container, Form, Modal, Alert, Toast, Tabs, and Sidebar fluent elements.
#![allow(unused)]
fn main() {
app()
.children([footer(false, FooterConfig::default())])
.run();
}
A Footer is a layout primitive for organizing content along the bottom of an interface.
It follows the same composition model as the Navbar, providing three named sections:
leftcenterright
The center section is always centered within the Footer. The left and right sections work inward from their respective edges.
The Footer can also contain arbitrary children(), making it useful for simple layouts as well as structured application footers.
The Footer provides the layout. The application provides the content.
Basic Usage
Create a Footer with footer() and compose content into its sections:
#![allow(unused)]
fn main() {
footer()
.left([
text("© 2026 Beverly"),
])
.center([
text("Built with Rust"),
])
.right([
text("v1.0.0"),
])
}
The three sections have predictable spatial behavior:
┌──────────────────────────────────────────────────────────┐
│ LEFT CENTER RIGHT │
│ ←─────── ─────── ───────→ │
└──────────────────────────────────────────────────────────┘
The center remains centered relative to the Footer rather than simply appearing after the left section.
Named Sections
The three named sections are semantic conveniences for common Footer layouts.
Left
left() places content against the left side of the Footer.
#![allow(unused)]
fn main() {
footer()
.left([
text("© 2026 Beverly"),
text("All rights reserved"),
])
}
The left section grows inward from the edge toward the center.
Center
center() places content in the center of the Footer.
#![allow(unused)]
fn main() {
footer()
.center([
text("Built with Rust"),
])
}
The center section is centered relative to the Footer itself.
It does not simply follow the left section.
Right
right() places content against the right side of the Footer.
#![allow(unused)]
fn main() {
footer()
.right([
button().text(localize("footer.settings")),
])
}
The right section grows inward from the right edge toward the center.
Using All Three
A typical application Footer might look like this:
#![allow(unused)]
fn main() {
footer()
.left([
text("© 2026 Beverly"),
])
.center([
text("Privacy"),
text("Terms"),
])
.right([
text("v1.0.0"),
])
}
The center remains centered even when the left and right sections contain different amounts of content.
This makes Footer layouts predictable without requiring application-specific positioning logic.
Children
children() remains the general composition primitive.
#![allow(unused)]
fn main() {
footer()
.children([
text("© 2026 Beverly"),
text("All rights reserved"),
])
}
This is useful when the application does not need the three-section layout.
Named sections are conveniences, not a replacement for children().
A Footer can therefore be used as a simple container:
#![allow(unused)]
fn main() {
footer()
.children([
text("© 2026 Beverly"),
])
}
or as a structured three-section layout:
#![allow(unused)]
fn main() {
footer()
.left(text("© 2026 Beverly"))
.center(text("Privacy"))
.right(text("v1.0.0"))
}
A section can accept one child or many children:
#![allow(unused)]
fn main() {
footer()
.left(text("Beverly"))
}
#![allow(unused)]
fn main() {
footer()
.left([
icon("copyright"),
text("2026 Beverly"),
])
}
children() is the general primitive. Named sections add useful semantics.
Fixed and Non-Fixed Footers
A Footer can either participate in normal layout flow or remain fixed relative to its containing interface.
For a normal Footer:
#![allow(unused)]
fn main() {
footer()
.left(text("© 2026 Beverly"))
.center(text("Privacy"))
.right(text("v1.0.0"))
}
For a fixed Footer:
#![allow(unused)]
fn main() {
footer()
.fixed(true)
.left(text("© 2026 Beverly"))
.center(text("Privacy"))
.right(text("v1.0.0"))
}
A fixed Footer remains positioned according to the application’s layout rules rather than moving with ordinary content flow.
The Footer does not decide whether fixed positioning is appropriate. It simply provides the option.
Styling
Footer uses the same fluent styling model as other Beverly components.
#![allow(unused)]
fn main() {
footer()
.padding(16)
.height(64)
.radius(12)
.left(text("© 2026 Beverly"))
.center(text("Privacy"))
.right(text("v1.0.0"))
}
Layout and presentation remain separate from application behavior.
The Footer determines where content goes. Its children determine what that content is.
Interactive Content
A Footer is not inherently interactive.
It can contain Buttons and other interactive components when the application needs them:
#![allow(unused)]
fn main() {
footer()
.left([
text("© 2026 Beverly"),
])
.right([
button().text(localize("footer.settings"))
.on("click", Command::Settings::Open),
])
}
The Footer does not need to know what the Command does.
It simply provides the layout in which the control appears.
The Footer provides the layout. The children provide the interaction. The application provides the behavior.
Data-Driven Footers
Footer content can come directly from application data:
#![allow(unused)]
fn main() {
footer()
.left(text(app.copyright()))
.center(footer_links())
.right(text(app.version()))
}
Higher-level pieces remain ordinary Rust functions:
#![allow(unused)]
fn main() {
fn footer_links() -> impl Component {
row()
.children([
button().text(localize("footer.privacy"))
.on("click", Command::Navigation::Privacy),
button().text(localize("footer.terms"))
.on("click", Command::Navigation::Terms),
])
}
}
There is no separate Footer component language.
Higher-level components are ordinary Rust functions that return lower-level components.
Accessibility
The Footer is primarily a layout primitive. Interactive elements inside it remain responsible for their own accessible semantics.
#![allow(unused)]
fn main() {
footer()
.left([
text("© 2026 Beverly"),
])
.right([
button().text(localize("footer.settings")),
])
}
The Footer can also expose accessible semantics when the application needs the footer region to be identified:
#![allow(unused)]
fn main() {
footer()
.label(localize("footer.label"))
.aria(localize("footer.description"))
.left([
text("© 2026 Beverly"),
])
.center([
text("Privacy"),
])
.right([
text("v1.0.0"),
])
}
The underlying semantic and layout behavior should be handled by Beverly rather than requiring developers to manually reproduce it.
Accessible by construction, not accessible by cleanup.
What Footer Does Not Do
Footer does not:
- own application state
- manage navigation state
- manage routing
- own application data
- define permissions
- define authentication
- require a separate layout system
- determine what footer content means
Those concerns belong to the application.
The Footer is responsible for organizing interface content.
Navbar and Footer
Navbar and Footer intentionally share the same basic layout vocabulary.
| Component | Purpose |
|---|---|
navbar() | Organizes content at the top or edge of an interface |
footer() | Organizes content at the bottom or edge of an interface |
Both provide:
children()left()center()right()- predictable centered positioning
- optional fixed positioning
- fluent styling
- ordinary Rust composition
The difference is semantic placement, not architectural complexity.
Same composition model. Different place in the interface.
Design Principle
The Footer deliberately provides only the structure that applications repeatedly need:
- content from the left edge
- content from the center
- content from the right edge
- predictable centered positioning
- optional fixed positioning
- general
children()composition
Everything else remains ordinary Beverly composition and ordinary Rust.
Three sections when you need them. children() when you don’t.
The center stays centered. The edges work inward. The application owns the meaning.
Feedback
Alert
Live Demo
Open the demo in a separate tab.
Header, Body, and Footer
UiElement::alert(...) supports top, middle, and bottom content regions:
#![allow(unused)]
fn main() {
UiElement::alert(Alert::warning("Storage is almost full"))
.header(text("Workspace storage"))
.body(text("Remove unused files before uploading more."))
.footer(button().text("Review files"))
}
.header(...), .body(...), and .footer(...) each accept one element or an
iterable; repeated calls append content. They render in that order in the
alert’s content column, regardless of builder-call order. Custom header content
precedes the built-in title/message, body content follows the message, and
footer actions appear last. The variant icon, accessible message, timer, and
dismiss control retain their existing behavior.
An Alert is a pre-styled Card used to communicate information to the user.
Alerts follow the familiar contextual pattern popularized by Bootstrap: a common surface with a predefined visual treatment for different kinds of information, such as success, warning, error, and informational messages.
Under the hood, an Alert is simply a Card with a predefined look and feel. It does not introduce a new layout system, state-management system, or application architecture.
The type is provided when the Alert is created:
#![allow(unused)]
fn main() {
alert(AlertType::Success)
}
This makes the semantic purpose of the component immediately visible in the code.
An Alert is a Card with an opinionated presentation.
Basic Alert
The simplest Alert presents a message:
#![allow(unused)]
fn main() {
alert(AlertType::Info)
.children(
text(localize("user.profile_updated"))
)
}
The Alert provides the visual treatment. The content provides the meaning.
A more complete Alert might contain an icon and text:
#![allow(unused)]
fn main() {
alert(AlertType::Info)
.children([
icon("info"),
text(localize("user.profile_description")),
])
}
The Alert does not need to understand what the message means. It simply presents its children using the visual language associated with the selected Alert type.
Alert Types
The Alert type is the first parameter because it is fundamental to what the component represents.
#![allow(unused)]
fn main() {
alert(AlertType::Info)
.children(text(localize("user.profile_updated")))
}
#![allow(unused)]
fn main() {
alert(AlertType::Success)
.children(text(localize("user.created")))
}
#![allow(unused)]
fn main() {
alert(AlertType::Warning)
.children(text(localize("user.unsaved_changes")))
}
#![allow(unused)]
fn main() {
alert(AlertType::Error)
.children(text(localize("user.create_failed")))
}
The type determines the predefined presentation. It does not determine application state.
Conceptually:
Alert Type
↓
Visual Treatment
↓
Alert Content
The application chooses AlertType::Error because it wants to present something as an error. The Alert does not create or detect that error.
The type describes the presentation. The application owns the meaning.
Why the Type Is a Parameter
The Alert type is not merely a style modifier.
Compare:
#![allow(unused)]
fn main() {
alert(AlertType::Success)
}
with:
#![allow(unused)]
fn main() {
alert()
.success()
}
The first form makes the semantic variant part of the component’s construction. You can understand the component by reading its first argument.
That makes the API particularly useful for AI-generated code:
#![allow(unused)]
fn main() {
alert(AlertType::Warning)
}
immediately communicates:
This is a warning Alert.
There is no need to inspect later configuration to discover the Alert’s primary semantic variant.
The remaining chained methods configure the Alert itself:
#![allow(unused)]
fn main() {
alert(AlertType::Warning)
.padding(16)
.children(...)
}
This follows a useful Beverly convention:
Construction establishes what something is. Configuration establishes how it behaves or looks.
Alerts Are Cards
An Alert is built on the Card primitive.
A general Card might look like:
#![allow(unused)]
fn main() {
card()
.padding(16)
.radius(12)
.children([
icon("info"),
text(localize("user.profile_updated")),
])
}
An Alert provides the same fundamental container behavior with a predefined semantic presentation:
#![allow(unused)]
fn main() {
alert(AlertType::Info)
.children([
icon("info"),
text(localize("user.profile_updated")),
])
}
The Alert saves the application from repeatedly defining the same visual recipe.
Instead of every part of an application independently deciding how an informational message should look, the design system establishes that convention once.
The implementation can remain simple because the Alert does not need to become a new UI framework. It is a specialized presentation of an existing primitive.
If it is fundamentally a Card, make it a Card. Give it a name when the name carries useful meaning.
Composition
Alerts use children() just like Cards.
A single child:
#![allow(unused)]
fn main() {
alert(AlertType::Success)
.children(
text(localize("user.saved"))
)
}
Multiple children:
#![allow(unused)]
fn main() {
alert(AlertType::Success)
.children([
icon("check"),
text(localize("user.saved")),
badge(localize("user.synced")),
])
}
More complex content can be composed normally:
#![allow(unused)]
fn main() {
alert(AlertType::Warning)
.children([
text(localize("user.unsaved_changes")),
button().text(localize("user.save"))
.on("click", Command::User::Save),
])
}
The Alert does not need special APIs for every possible message layout.
It provides the visual container and the predefined treatment. children() provides composition.
Styling
Alerts have a predefined visual identity, but they remain Beverly components and can participate in normal styling.
#![allow(unused)]
fn main() {
alert(AlertType::Success)
.padding(16)
.radius(12)
.children([
icon("check"),
text(localize("user.saved")),
])
}
The Alert type establishes its default visual treatment. Additional styling can refine the component where appropriate.
This keeps the API expressive without forcing developers to manually reproduce the Alert design.
#![allow(unused)]
fn main() {
alert(AlertType::Error)
}
is enough to get the application’s standard error presentation.
The developer does not need to know which colors, borders, spacing, typography, or icon treatment make up that presentation.
Declare the meaning. Let the component provide the convention.
Accessibility
An Alert communicates information, so its accessible semantics should be explicit.
#![allow(unused)]
fn main() {
alert(AlertType::Error)
.label(localize("user.create_status.label"))
.aria(localize("user.create_status.description"))
.children([
icon("error"),
text(localize("user.create_failed")),
])
}
The Alert’s accessibility metadata describes the communication surface itself.
Its children retain their own accessibility requirements. An interactive Button inside an Alert still declares its own label and ARIA description:
#![allow(unused)]
fn main() {
alert(AlertType::Warning)
.children([
text(localize("user.unsaved_changes")),
button().text(localize("user.save"))
.on("click", Command::User::Save),
])
}
Accessibility therefore remains compositional.
The Alert provides semantics for the message container. Interactive children provide their own semantics.
Color alone should not be relied upon to communicate the meaning of an Alert; the content or accessible semantics should make that meaning available to assistive technology as well. This is also a documented concern in Bootstrap’s Alert guidance.
The visual treatment communicates. The accessible semantics make that communication explicit.
Static Alerts
An Alert does not need to be connected to changing application state.
It can simply communicate information that is always present:
#![allow(unused)]
fn main() {
alert(AlertType::Info)
.children(
text(localize("user.profile_public"))
)
}
The Alert is simply part of the View.
There is no requirement that every Alert represent a changing state.
Data-Driven Alerts
An Alert can also be generated from application state.
#![allow(unused)]
fn main() {
if User::has_error {
alert(AlertType::Error)
.children(
text(User::error_message)
)
}
}
Or different application conditions can determine which presentation should be used:
#![allow(unused)]
fn main() {
if User::is_saved {
alert(AlertType::Success)
.children(text(localize("user.saved")))
} else if User::has_error {
alert(AlertType::Error)
.children(text(User::error_message))
} else {
alert(AlertType::Info)
.children(text(localize("user.ready")))
}
}
The Alert is still not responsible for deciding which condition is true.
The Model owns the state. The View chooses the appropriate presentation.
Alert Type Is Not Application State
This distinction is important.
Consider:
#![allow(unused)]
fn main() {
alert(AlertType::Error)
.children(text(User::error_message))
}
The Alert type says:
Present this content using the error treatment.
It does not mean:
Change the application’s state to Error.
The application already knows that an error exists. The View chooses the Alert presentation that communicates that state to the user.
This keeps presentation separate from state management.
Presentation describes state. It does not create state.
Alert Events
An Alert does not inherently represent an interaction.
Unlike a Button, an Alert’s primary responsibility is communication. It does not need to emit an Event merely because it exists.
If an Alert contains an interactive component, that component produces its own UI Events.
#![allow(unused)]
fn main() {
alert(AlertType::Warning)
.children([
text(localize("user.unsaved_changes")),
button().text(localize("user.save"))
.on("click", Command::User::Save),
])
}
The Alert remains a presentation container. The Button handles the interaction.
An Alert communicates. Interactive children interact.
Dismissible Alerts
An Alert can optionally contain a control that allows the user to dismiss it.
The dismissal interaction belongs to the child Button:
#![allow(unused)]
fn main() {
alert(AlertType::Info)
.children([
text(localize("user.profile_updated")),
button().text(localize("user.dismiss"))
.on("click", Command::Alert::Dismiss),
])
}
The Alert does not need to become a notification manager or invent its own dismissal architecture.
The application decides what Command::Alert::Dismiss means and which application state should change.
This is an important difference from frameworks where an Alert component may contain its own imperative dismissal mechanism. Beverly keeps the application state in the application and uses the normal Event → Controller → Model architecture.
Alert State
An Alert can also participate in normal component state presentation.
For example:
#![allow(unused)]
fn main() {
alert(AlertType::Error)
.hover(|style| style.opacity(0.95))
.focused(|style| style.outline_width(2).outline_color("#2563eb"))
.children([
icon("error"),
text(User::error_message),
])
}
These are presentation states of the component. They do not change what kind of Alert it is.
The type remains:
#![allow(unused)]
fn main() {
AlertType::Error
}
while the component’s visual state can change based on interaction or application state.
This gives us two separate concepts:
Alert Type
↓
What kind of message is being presented?
Component State
↓
How should the Alert currently appear?
That separation prevents the two concepts from becoming tangled.
Alert and Localization
Alert content should normally come from the application’s localization system.
#![allow(unused)]
fn main() {
alert(AlertType::Success)
.children(
text(localize("user.created"))
)
}
The Alert type itself is not language-specific. It provides the presentation convention.
For dynamic errors:
#![allow(unused)]
fn main() {
alert(AlertType::Error)
.children(
text(localize(User::error_key))
)
}
The Model provides semantic state or error information. Localization provides the appropriate language. The Alert presents it.
The Model returns meaning. The UI presents language.
Alert as a Design-System Primitive
The real value of Alert is consistency.
Without an Alert primitive, different parts of an application might independently implement success messages:
green Card
green banner
green notification
green bordered container
Each implementation could look slightly different.
With an Alert:
#![allow(unused)]
fn main() {
alert(AlertType::Success)
.children(...)
}
the design system establishes the convention once.
The application gets consistent presentation without requiring every developer or AI agent to reconstruct the design.
This is particularly useful for AI-generated interfaces. An agent does not need to invent a visual treatment every time it needs to communicate success or failure. It can use the existing semantic primitive.
Alert vs. Card
The relationship is deliberately simple.
| Component | Purpose |
|---|---|
card() | General-purpose content container |
alert(type) | Pre-styled Card for communicating information |
modal() | Temporary Card-like surface with an interaction lifecycle |
A Card is general.
An Alert is opinionated.
A Modal adds a temporary interaction boundary.
All three share the same underlying composition philosophy.
Alert vs. Modal
An Alert and Modal can both communicate an error or success state, but they serve different purposes.
An Alert is part of the existing interface:
#![allow(unused)]
fn main() {
alert(AlertType::Error)
.children(
text(localize("user.create_failed"))
)
}
A Modal temporarily places content above the existing interface:
#![allow(unused)]
fn main() {
modal()
.label(localize("user.create_error.label"))
.aria(localize("user.create_error.description"))
.children([
text(localize("user.create_failed")),
])
}
The difference is not the message. It is the interaction surface.
The Alert communicates within the current interface. The Modal establishes a temporary interface context.
Alert Contract
The Alert has a deliberately small conceptual contract:
Create with a type. Compose. Present.
It provides:
- predefined information, success, warning, and error treatments
- Card-based composition
children()support- consistent design-system presentation
- accessibility semantics
- optional styling refinement
- optional interactive children
It does not own:
- application state
- business logic
- validation
- application Commands
- database operations
- API operations
- agent behavior
- notification infrastructure
Those concerns remain outside the Alert.
The Alert is therefore a small, semantic abstraction over Card rather than another subsystem.
The Principle
The Alert should be one of Beverly’s simplest components.
Its API says exactly what it is:
#![allow(unused)]
fn main() {
alert(AlertType::Success)
}
Then the rest of the configuration describes its contents:
#![allow(unused)]
fn main() {
alert(AlertType::Success)
.children([
icon("check"),
text(localize("user.created")),
])
}
The type carries the semantic presentation. children() carries the content. The application state determines when the Alert exists. The Model owns the underlying meaning.
There is no need for an Alert state machine, notification framework, or separate composition system.
Create the primitive with its meaning. Configure the presentation. Compose the content.
Modal
Live Demo
Open the demo in a separate tab.
Header, Body, and Footer
UiElement::modal(...) implements .header(...), .body(...), and
.footer(...) as top, middle, and bottom regions inside the modal surface.
Each accepts a single element or an iterable, repeated calls append content,
and sections render in that order regardless of builder-call order.
#![allow(unused)]
fn main() {
use beverly::components::modal::{BasicModalContent, ModalStyle};
use beverly::prelude::*;
UiElement::modal(
Modal::new(),
ModalStyle::default(),
BasicModalContent::new("Account", ""),
)
.header(text("Edit account details"))
.body(UiElement::input("Email").label("Email address"))
.footer(button().text("Save"))
}
The built-in dialog title and close button stay above custom sections. The
body grows into available surface height, leaving footer content at the
bottom. Custom sections replace the basic body and dismiss-button content;
without sections, BasicModalContent supplies the title, body, and dismiss
button. Sections support nested, resource-backed UiElement composition.
For specialized construction, spawn_modal_with_surface(...) remains available.
A Modal is a temporary surface that appears above the application’s current interface.
It is a specialized container for content that requires the user’s attention without navigating away from the current application context. A Modal can contain forms, confirmations, settings, information, workflows, or other composed Beverly components.
Like a Card, a Modal does not own application data or business logic. Its responsibility is to present temporary content, manage its UI interaction state, and expose UI Events. The application decides when a Modal should appear and what its interaction means.
The Modal controls the surface. The application controls the state.
Basic Modal
A Modal contains composed content just like a Card.
#![allow(unused)]
fn main() {
modal()
.children([
text(localize("user.create")),
text(localize("user.create.description")),
])
}
A more complete Modal might contain a header, body, and footer:
#![allow(unused)]
fn main() {
modal()
.header(
text(localize("user.create"))
)
.body([
text(localize("user.create.description")),
input()
.label(localize("user.name.label"))
.aria(localize("user.name.description"))
.bind(User::name),
])
.footer(
button().text(localize("user.create"))
)
}
The Modal provides the temporary surface. Its children provide the actual interface.
There is no separate composition system for Modal. It uses the same children() model as the rest of Beverly.
Modal as a Specialized Card
Conceptually, a Modal is a Card with additional behavior.
A Card provides:
- containment
- composition
- styling
- state-specific presentation
A Modal adds:
- visibility
- temporary presentation
- focus management
- dismissal behavior
- modal interaction Events
- optional backdrop
- accessibility semantics appropriate to a dialog
This means Modal should not introduce an entirely different UI model. It extends a familiar primitive with the behavior required for a temporary surface.
A Modal is a Card with a lifecycle.
Visibility
The application state determines whether a Modal is visible.
#![allow(unused)]
fn main() {
modal()
.visible(User::is_create_modal_open)
.children([
text(localize("user.create")),
input()
.label(localize("user.name.label"))
.aria(localize("user.name.description"))
.bind(User::name),
])
}
The Modal does not decide why it is open. It simply reflects the state supplied by the application.
For example, the Model might contain:
#![allow(unused)]
fn main() {
struct User {
create_modal_open: bool,
name: String,
}
}
The application can then change that state through its normal Event and Controller architecture.
The important boundary is:
Application State
↓
Modal visibility
↓
Modal presentation
The Modal is therefore not a second state-management system.
Opening a Modal
Opening a Modal is application behavior, not a special Modal command.
A Button might produce a UI Event:
#![allow(unused)]
fn main() {
button().text(localize("user.create"))
.on("click", Ui::CreateButton::Clicked)
}
The application can then connect that Event to a Command:
#![allow(unused)]
fn main() {
Ui::CreateButton::Clicked
→ Command::User::OpenCreate
}
The Controller handles the relationship between the UI Event and the application action.
The Modal itself does not need to know that a Button opened it.
This allows the same Modal to be opened by a Button, keyboard shortcut, command palette, agent, API-driven state change, or any other application mechanism.
The Modal does not open itself. Application state makes it visible.
Closing a Modal
A Modal can expose UI Events for dismissal.
#![allow(unused)]
fn main() {
modal()
.on("close", Ui::CreateModal::CloseRequested)
}
The Event describes what happened to the UI. It does not directly mutate application state.
The application can translate that Event into a Command:
#![allow(unused)]
fn main() {
Ui::CreateModal::CloseRequested
→ Command::User::CloseCreate
}
The Controller then performs the appropriate application action, which updates the Model.
The resulting flow is:
User interaction
↓
UI Event
↓
Controller
↓
Command
↓
Model
↓
Modal visibility
This keeps opening and closing consistent with the rest of Beverly’s architecture.
Dismissal
A Modal may support several UI-level dismissal interactions.
For example:
#![allow(unused)]
fn main() {
modal()
.on("close", Ui::CreateModal::CloseRequested)
.on("escape", Ui::CreateModal::EscapePressed)
.on("backdrop_click", Ui::CreateModal::BackdropClicked)
}
These are UI Events. The application decides whether each one should actually close the Modal.
For example, an application might allow Escape to dismiss an informational Modal but require an explicit decision before dismissing a destructive workflow.
The Modal should not embed that business rule.
The component reports:
Escape happened.
Backdrop was clicked.
Close was requested.
The application decides:
Should this Modal close?
This distinction becomes particularly important for forms and destructive operations.
Modal Interaction Events
Modal interaction can be expressed through abstract UI Events.
#![allow(unused)]
fn main() {
modal()
.on("open", Ui::CreateModal::Opened)
.on("close", Ui::CreateModal::Closed)
.on("escape", Ui::CreateModal::EscapePressed)
.on("backdrop_click", Ui::CreateModal::BackdropClicked)
}
These Events describe the Modal’s lifecycle and interaction.
They should not be confused with application Commands such as:
#![allow(unused)]
fn main() {
Command::User::Create
Command::User::CloseCreate
Command::User::CancelCreate
}
The UI Events describe what happened to the interface. The Commands describe what the application should do.
This makes the Modal useful outside any particular domain.
Events describe the interface. Commands describe the application.
Modal Content
Modal content is ordinary Beverly composition.
#![allow(unused)]
fn main() {
modal()
.header([
icon("user"),
text(localize("user.create")),
])
.body([
input()
.label(localize("user.name.label"))
.aria(localize("user.name.description"))
.bind(User::name),
input()
.label(localize("user.email.label"))
.aria(localize("user.email.description"))
.bind(User::email),
])
.footer([
button().text(localize("user.cancel"))
.on("click", Command::User::CancelCreate),
button().text(localize("user.create"))
.on("click", Command::User::Create),
])
}
The Modal does not need to know that these controls represent user creation.
It provides the surface. The components inside it provide the interface.
Forms Inside Modals
A Modal is often useful for temporary workflows such as creating or editing a record.
The Form remains responsible for collecting input. The Model remains responsible for application state and validation.
#![allow(unused)]
fn main() {
let create_user_form = UiElement::form(Form::post("/api/users"))
.children([
UiElement::input("Name")
.name("name")
.bind(User::name),
UiElement::input("Email")
.name("email")
.bind(User::email),
])
.submitting(|| [spinner(), text("Creating user…")]);
}
Place the resulting Form in the Modal’s body. Its generated submit button emits
FormSubmitted; the app handles the request and updates FormStatusState on
the Form entity. The Modal does not become the Form, and the Form does not
become the Model. See Submit.
Each component retains its responsibility.
Modal
└── Form
├── Input
├── Input
└── Submit Button
The Modal provides the temporary surface. The Form collects input. Bindings connect fields to the Model. Commands initiate application behavior.
Focus
A Modal changes the user’s interaction context, so focus is part of its UI behavior.
When a Modal opens, the application should be able to establish an appropriate focus target. When it closes, focus should return to the appropriate location in the underlying interface.
A Modal can express this through its configuration:
#![allow(unused)]
fn main() {
modal()
.focus(User::create_modal_focus_target)
.children([
text(localize("user.create")),
input()
.label(localize("user.name.label"))
.aria(localize("user.name.description"))
.bind(User::name),
])
}
The exact focus target is an application decision. Beverly’s responsibility is to provide the mechanism for managing focus within the temporary surface.
This is especially important for keyboard users because opening a Modal should establish a predictable interaction context rather than leaving focus behind the overlay.
Focus Containment
While a Modal is active, keyboard interaction should remain within the Modal until it is dismissed.
This prevents keyboard navigation from unexpectedly moving into the obscured application behind the Modal.
Conceptually:
Underlying Application
↓
inactive
↑
Modal
↓
active focus
The Modal therefore manages the UI mechanics of its temporary interaction boundary without owning the application’s state.
Backdrop
A Modal can provide a backdrop separating the temporary surface from the underlying application.
#![allow(unused)]
fn main() {
modal()
.backdrop(true)
.children([
text(localize("user.create")),
])
}
The backdrop is presentation. Whether clicking it dismisses the Modal is interaction policy.
Those can therefore remain separate:
#![allow(unused)]
fn main() {
modal()
.backdrop(true)
.on("backdrop_click", Ui::CreateModal::BackdropClicked)
}
The Modal reports the interaction. The application decides what to do with it.
This distinction prevents presentation choices from becoming hidden application behavior.
Modal State
Modal styles can vary with interaction state, while its success/submitting
callbacks add separate content beneath the Modal surface. The app supplies the
FormStatusState; the Modal doesn’t infer it from a request or predicate.
#![allow(unused)]
fn main() {
UiElement::modal(Modal::new(), ModalStyle::default(), BasicModalContent::new("Create user", ""))
.hover(|style| style.opacity(0.96))
.focused(|style| style.outline_width(2).outline_color("#2563eb"))
.submitting_style(|style| style.opacity(0.72))
.success_style(|style| style.border_color("#16a34a"))
.submitting(|| [
spinner(),
text(localize("user.creating")),
])
.success(|| text(localize("user.created")))
}
Set FormStatusState::submitting(), success(), or idle() on the spawned
Modal entity from application code. submitting/success callbacks append
supplementary content below the existing modal content; state-style callbacks
change only the listed style properties. For method details and precedence,
see Styling DSL and
Form status callbacks.
Error State
A Modal can present an error without implementing the operation that produced it.
#![allow(unused)]
fn main() {
modal()
.error(|errors| text(errors.join("\n")))
}
The underlying error should come from application state and should normally be localized at presentation time.
The Modal should not contain domain validation such as:
email is invalid
name is required
user already exists
Those rules belong to the Model or application layer.
The Modal presents the resulting state.
Success State
The same principle applies to successful operations.
#![allow(unused)]
fn main() {
modal()
.success(|| [
icon("check"),
text(localize("user.created")),
])
}
The Modal does not decide whether the operation succeeded. It presents the success state supplied by the application.
This makes the same Modal reusable for many different workflows.
Preventing Accidental Dismissal
Some Modal workflows should not disappear simply because the user interacts with the backdrop or presses Escape.
The Modal can expose the interaction, while the application decides what to do:
#![allow(unused)]
fn main() {
modal()
.on("escape", Ui::CreateModal::EscapePressed)
.on("backdrop_click", Ui::CreateModal::BackdropClicked)
}
The Controller can then apply the application’s policy.
For example, if a Form contains unsaved changes, the application might respond to a close request by showing a confirmation Modal rather than immediately closing the current one.
The Modal itself does not need to understand “unsaved changes.” That information belongs to application state.
Nested Modals
Beverly should make it possible to build layered workflows, but nested Modals should remain an explicit application decision rather than an implicit behavior of the component.
A confirmation surface can be composed independently:
#![allow(unused)]
fn main() {
modal()
.header(text(localize("user.discard_changes")))
.body(text(localize("user.discard_changes.description")))
.footer([
button().text(localize("user.keep_editing")),
button().text(localize("user.discard")),
])
}
The application determines when that Modal appears and what each interaction means.
The component remains the same primitive regardless of the workflow.
Accessibility
A Modal is more than a visual box. When presented as a dialog, it needs an explicit accessible identity and description.
#![allow(unused)]
fn main() {
modal()
.label(localize("user.create.label"))
.aria(localize("user.create.description"))
.children([
text(localize("user.create")),
input()
.label(localize("user.name.label"))
.aria(localize("user.name.description"))
.bind(User::name),
])
}
The Modal’s accessible semantics describe the dialog itself. Its children retain their own accessibility declarations.
For example, the Input still needs:
#![allow(unused)]
fn main() {
input()
.label(localize("user.name.label"))
.aria(localize("user.name.description"))
.bind(User::name)
}
Accessibility is therefore compositional. The Modal provides dialog-level semantics, while each interactive child provides its own semantics.
A complete Modal should also provide predictable keyboard behavior, focus containment, and a clear mechanism for dismissal where appropriate.
Accessibility belongs to the interaction boundary, not just the pixels.
Styling
Modal styling follows the same fluent configuration model as Card.
#![allow(unused)]
fn main() {
modal()
.padding(24)
.radius(16)
.shadow(12)
.max_width(640)
.children([
text(localize("user.create")),
])
}
The visual treatment can be customized without changing the Modal’s underlying behavior.
This allows a design system to establish consistent Modal primitives while individual applications compose their own content.
Complete Example
A complete create-user Modal brings the pieces together:
#![allow(unused)]
fn main() {
modal()
.label(localize("user.create.label"))
.aria(localize("user.create.description"))
.visible(User::is_create_modal_open)
.padding(24)
.radius(16)
.backdrop(true)
.header([
icon("user"),
text(localize("user.create")),
])
.body([
input()
.label(localize("user.name.label"))
.aria(localize("user.name.description"))
.bind(User::name),
input()
.label(localize("user.email.label"))
.aria(localize("user.email.description"))
.bind(User::email),
])
.footer([
button().text(localize("user.cancel"))
.on("click", Command::User::CancelCreate),
button().text(localize("user.create"))
.submitting_style(|style| style.opacity(0.5))
.on("click", Command::User::Create),
])
.submitting(|| [spinner(), text(localize("user.creating"))])
.error(|errors| text(errors.join("\n")))
.on("escape", Ui::CreateModal::EscapePressed)
.on("backdrop_click", Ui::CreateModal::BackdropClicked)
}
There are several independent responsibilities here, but they remain easy to identify.
The Modal owns its presentation and temporary interaction boundary. Inputs bind
to Model state; Buttons produce UI interactions; Commands represent application
operations. The application sets FormStatusState on the spawned Modal entity
after deciding whether the operation is pending or complete. Callbacks render
supplementary content; they do not evaluate application predicates themselves.
Nothing requires the Modal to understand the User domain.
The Modal Contract
The Modal has a deliberately small conceptual contract:
Create. Configure. Compose. Present. Contain interaction. Emit UI Events.
It provides:
- temporary visual presentation
- composition through
children() - semantic sections such as
header,body, andfooter - visibility
- backdrop presentation
- focus management
- keyboard interaction
- dismissal Events
- state-specific presentation
- dialog accessibility semantics
It does not own:
- application data
- business rules
- domain validation
- application Commands
- database operations
- API operations
- agent behavior
Those concerns remain in the application’s Model, Events, and Controller.
The result is a Modal that behaves like the rest of Beverly: familiar Rust, ordinary composition, explicit Events, and clear ownership.
The Modal is a temporary interface, not a second application state system.
Toast
Live Demo
Open the demo in a separate tab.
Toast supports the same fluent .header(...), .body(...), and
.footer(...) section helpers as Card. The standalone footer(...) function
creates the separate Footer component.
Header, Body, and Footer
Use UiElement::toast(...) to compose top, middle, and bottom regions:
#![allow(unused)]
fn main() {
UiElement::toast(Toast::success("Changes saved").title("Saved"))
.header(text("Account update"))
.body(text("All devices will receive the update."))
.footer(button().text("View account"))
}
Each section accepts one element or an iterable, and repeated calls append content. Header, body, and footer render vertically in that order regardless of builder-call order. The header appears above the notification row; custom body content follows the built-in title/message within that row; footer content appears beneath it, not beside it. The icon, dismiss control, duration, positioning, and notification lifecycle remain intact.
A Toast is a transient notification presented as a floating Card.
It is a composite component built from familiar Beverly primitives: a Card, content, a close Button, positioning, animation, and a timed lifecycle.
A Toast appears with an entrance animation, remains visible for a configurable period, and then fades away.
It can also be dismissed immediately by the user.
A Toast is a Card with a lifecycle.
Basic Usage
Create a Toast with toast() and choose its type:
#![allow(unused)]
fn main() {
toast(ToastType::Success)
.children([
text(localize("user.saved")),
])
}
Other predefined types follow the same semantic vocabulary as Alert:
#![allow(unused)]
fn main() {
toast(ToastType::Info)
.children([
text(localize("user.updated")),
])
}
#![allow(unused)]
fn main() {
toast(ToastType::Warning)
.children([
text(localize("user.unsaved_changes")),
])
}
#![allow(unused)]
fn main() {
toast(ToastType::Error)
.children([
text(localize("user.save_failed")),
])
}
The type determines the Toast’s presentation.
It does not create application state.
The type describes the presentation. The application owns the meaning.
Toast vs Alert
Toast and Alert share a semantic vocabulary, but they serve different presentation models.
| Alert | Toast |
|---|---|
| Persistent/in-context | Transient/floating |
| Remains visible | Automatically dismisses |
| Communicates within the layout | Appears above the layout |
| Primarily contextual | Primarily immediate feedback |
| No inherent entrance lifecycle | Enters and exits with animation |
| Card presentation | Composite animated Card |
An Alert might communicate:
#![allow(unused)]
fn main() {
alert(AlertType::Error)
.children([
text(localize("user.save_failed")),
])
}
A Toast communicates the same kind of information differently:
#![allow(unused)]
fn main() {
toast(ToastType::Error)
.children([
text(localize("user.save_failed")),
])
}
The semantic type is similar. The presentation and lifecycle are different.
Alert communicates in context. Toast communicates temporarily above the interface.
Content and Named Sections
children() is the general composition primitive.
#![allow(unused)]
fn main() {
toast(ToastType::Success)
.children([
text(localize("user.saved")),
text(localize("user.saved_description")),
])
}
For more structured content, Toast provides three named sections:
headerbodyfooter
These sections are semantic conveniences, not a replacement for children().
A simple Toast can use children():
#![allow(unused)]
fn main() {
toast(ToastType::Info)
.children(
text(localize("user.profile_updated"))
)
}
A structured Toast can use named sections:
#![allow(unused)]
fn main() {
toast(ToastType::Success)
.header(
text(localize("user.saved"))
)
.body(
text(localize("user.saved_description"))
)
.footer(
text(localize("user.saved_time"))
)
}
Each named section can accept one child or many children:
#![allow(unused)]
fn main() {
toast(ToastType::Success)
.header([
icon("check"),
text(localize("user.saved")),
])
}
#![allow(unused)]
fn main() {
toast(ToastType::Success)
.body([
text(localize("user.saved")),
text(localize("user.saved_description")),
])
}
#![allow(unused)]
fn main() {
toast(ToastType::Success)
.footer([
text(localize("user.saved_time")),
text(localize("user.saved_location")),
])
}
The named sections provide useful structure without introducing another composition model.
children() is the general primitive. header, body, and footer add useful semantics.
Header
The Header is useful for a short title or primary message.
#![allow(unused)]
fn main() {
toast(ToastType::Error)
.header(
text(localize("user.save_failed"))
)
}
It can contain multiple components:
#![allow(unused)]
fn main() {
toast(ToastType::Error)
.header([
icon("error"),
text(localize("user.save_failed")),
])
}
The Header does not own the error state. It simply presents content.
Body
The Body is useful for supporting information.
#![allow(unused)]
fn main() {
toast(ToastType::Error)
.body(
text(localize("user.save_failed_description"))
)
}
It can contain arbitrary components:
#![allow(unused)]
fn main() {
toast(ToastType::Error)
.body([
text(localize("user.save_failed_description")),
text(localize("user.retry_available")),
])
}
The Toast does not interpret the content.
The application provides the meaning. The Toast provides the presentation.
Footer
The Footer is useful for secondary actions or supporting information.
#![allow(unused)]
fn main() {
toast(ToastType::Info)
.footer([
button().text(localize("user.undo"))
.on("click", Command::User::UndoDelete),
])
}
The Footer can contain multiple components:
#![allow(unused)]
fn main() {
toast(ToastType::Info)
.footer([
text(localize("user.deleted")),
button().text(localize("user.undo"))
.on("click", Command::User::UndoDelete),
])
}
The Button remains an ordinary Beverly Button. The Toast does not redefine its behavior.
Close Button
A Toast includes a close control so the user can dismiss it before its timer expires.
The close control is part of the Toast’s presentation rather than application content.
Its accessible name should clearly communicate its purpose:
#![allow(unused)]
fn main() {
toast(ToastType::Info)
.close_label(localize("toast.dismiss"))
.children([
text(localize("user.profile_updated")),
])
}
The user can therefore either wait for the Toast to expire or dismiss it immediately.
The application does not need to construct a separate close-button architecture for every Toast.
The Toast manages its presentation lifecycle. The application decides when the Toast exists.
Position
A Toast can be positioned at the top or bottom of the interface and aligned left, center, or right.
#![allow(unused)]
fn main() {
toast(ToastType::Success)
.position(ToastPosition::TopRight)
.children([
text(localize("user.saved")),
])
}
Other positions include:
#![allow(unused)]
fn main() {
toast(ToastType::Info)
.position(ToastPosition::TopLeft)
}
#![allow(unused)]
fn main() {
toast(ToastType::Info)
.position(ToastPosition::TopCenter)
}
#![allow(unused)]
fn main() {
toast(ToastType::Info)
.position(ToastPosition::BottomLeft)
}
#![allow(unused)]
fn main() {
toast(ToastType::Info)
.position(ToastPosition::BottomCenter)
}
#![allow(unused)]
fn main() {
toast(ToastType::Info)
.position(ToastPosition::BottomRight)
}
The position is a property of the Toast’s presentation, not its application meaning.
Same Toast. Different place in the interface.
Duration
A Toast remains visible for a configurable period before beginning its exit animation.
#![allow(unused)]
fn main() {
toast(ToastType::Success)
.duration(5000)
.children([
text(localize("user.saved")),
])
}
The duration is expressed in milliseconds.
A longer duration can be used when the content requires more time to read:
#![allow(unused)]
fn main() {
toast(ToastType::Info)
.duration(8000)
.children([
text(localize("user.import_complete")),
])
}
The duration belongs to the Toast’s presentation lifecycle, not the application’s Model.
Entrance and Exit Animation
Animation is a fundamental part of the Toast primitive.
When the Toast appears, it enters the interface using its entrance animation.
When its duration expires, it exits before being removed.
Conceptually:
Create
↓
Entering
↓
Visible
↓
Timer / Dismiss
↓
Exiting
↓
Removed
The animation can naturally reflect the Toast’s position.
A Toast entering from the bottom can move upward into place, while a Toast entering from the top can move downward.
The exact animation remains an implementation detail of the Toast primitive.
The Toast owns the animation. The application does not have to orchestrate it.
Lifecycle
The Toast has presentation state internally:
- opening
- visible
- closing
- closed
The application does not need to manage these states manually.
This is UI state, not application state.
The Model continues to own application state while the Toast manages its own temporary presentation lifecycle.
Presentation state belongs to the component. Application state belongs to the Model.
Application Events
A Toast can be triggered by an application Event:
#![allow(unused)]
fn main() {
controller! {
User::Saved => [
Toast::Success,
],
}
}
The Event describes what happened. The Toast determines how that fact is presented.
The Toast does not need to know why the application produced it.
Events describe facts. Toast describes presentation.
Data-Driven Toasts
Toast content can come directly from application data:
#![allow(unused)]
fn main() {
toast(notification.toast_type())
.children([
text(notification.message()),
])
}
An application can also map its own domain types to Toast presentation types:
#![allow(unused)]
fn main() {
enum DeploymentStatus {
Running,
Failed,
RolledBack,
}
}
Then ordinary Rust determines how those domain states should be presented:
#![allow(unused)]
fn main() {
fn toast_type(status: DeploymentStatus) -> ToastType {
match status {
DeploymentStatus::Running => ToastType::Success,
DeploymentStatus::Failed => ToastType::Error,
DeploymentStatus::RolledBack => ToastType::Warning,
}
}
}
The framework provides useful defaults without defining the application’s domain vocabulary.
Your data, your type system.
Higher-Level Toasts
Building a higher-level Toast is just an ordinary Rust function that returns a Toast.
#![allow(unused)]
fn main() {
fn saved_toast() -> Toast {
toast(ToastType::Success)
.header(
text(localize("user.saved"))
)
.body(
text(localize("user.saved_description"))
)
}
}
Use it like any other component:
#![allow(unused)]
fn main() {
saved_toast()
}
There is no nested Toast component or special extension system.
Higher-level components are ordinary Rust functions that return lower-level components.
Accessibility
A Toast communicates information that may appear outside the user’s current reading context, so its announcement behavior is part of the component’s implementation.
The close control must be keyboard accessible and have an accessible name.
#![allow(unused)]
fn main() {
toast(ToastType::Success)
.close_label(localize("toast.dismiss"))
.children([
text(localize("user.saved")),
])
}
The Toast should not depend on color or animation alone to communicate meaning.
The semantic type and accessible announcement provide the meaning; visual treatment reinforces it.
Accessible by construction, not accessible by cleanup.
What Toast Does Not Do
Toast does not:
- own application state
- replace the Model
- become a notification database
- manage application business logic
- define domain types
- replace Alert
- replace Modal
- require a separate state-management system
It owns its presentation lifecycle:
- positioning
- entrance animation
- visibility duration
- exit animation
- dismissal
- close-button presentation
- content composition
The application owns the reason the Toast exists.
Alert, Toast, and Modal
These components can share underlying primitives while serving different purposes.
| Component | Purpose |
|---|---|
alert(type) | Persistent communication within the interface |
toast(type) | Transient floating feedback |
modal() | Temporary surface requiring focused interaction |
The distinction is primarily lifecycle and presentation.
Alert communicates. Toast appears and fades. Modal interrupts.
Design Principle
Toast is deliberately a composite component rather than a new UI system.
Conceptually, it combines:
Toast
├── Card
├── Header / Body / Footer
├── Close Button
├── Position
├── Entrance Animation
├── Duration
└── Exit Animation
Each piece already exists conceptually in Beverly.
Toast simply combines them into a useful, reusable pattern.
The Toast is a specialized Card with a lifecycle.
Create it with a semantic type. Configure its position and duration. Compose its content with children() or header, body, and footer. Let Beverly manage the presentation lifecycle.
Create. Configure. Compose. Animate. Dismiss.
Tooltip
Live Demo
Open the demo in a separate tab.
A Tooltip is a small text primitive that provides additional context about another UI element.
Tooltips are useful for explaining unfamiliar controls, providing concise descriptions, revealing secondary information, or giving additional context without permanently occupying interface space.
A Tooltip does not own the meaning of the thing it describes. It simply provides additional information about its target.
A Tooltip explains. The component it describes provides the interaction.
Basic Usage
A Tooltip can be attached to a component:
#![allow(unused)]
fn main() {
button().text(localize("user.save"))
.on("click", Command::User::Save)
}
The Button remains the interactive element.
The Tooltip provides additional context.
The application action still belongs to the Button and its Event/Controller wiring.
This keeps the responsibilities separate:
Component
↓
Tooltip
↓
Additional Context
The Tooltip does not become another application state system simply because it appears in response to interaction.
Tooltip Is Text
Like Badge, Tooltip is fundamentally a form of text.
Its job is to present a small amount of additional information.
#![allow(unused)]
fn main() {
button().text(localize("user.delete"))
}
The Tooltip can contain localized application text:
Or application data when additional context is data-driven:
#![allow(unused)]
fn main() {
text(User::name)
}
The Tooltip does not need to understand where the text came from.
The text could be static, localized, Model-derived, generated from application metadata, or produced by another system.
The Tooltip presents context. The Model owns data.
Tooltips Are Context, Not Controls
A Tooltip should not be confused with an interactive component.
Consider:
#![allow(unused)]
fn main() {
button().text(localize("user.delete"))
.on("click", Command::User::Delete)
}
The Button receives the interaction.
The Tooltip does not.
The user hovering or focusing the Button may cause the Tooltip to become visible, but the Tooltip itself is not the thing being activated.
The conceptual relationship is:
User Interaction
↓
Target Component
↓
Tooltip Presentation
This prevents a common architectural mistake: treating every visible UI reaction as an application Event.
Showing contextual help is generally a UI presentation concern.
Deleting a user is an application action.
Those are fundamentally different things.
Tooltip Visibility
Tooltip visibility is normally controlled by the UI lifecycle rather than application state.
For example, Beverly can show a Tooltip when its target is hovered or focused.
#![allow(unused)]
fn main() {
button().text(localize("user.save"))
}
Conceptually:
Pointer Hover
↓
Tooltip Visible
Keyboard Focus
↓
Tooltip Visible
The application does not need to create a User::tooltip_open field just because a Tooltip appears.
This is an important distinction:
UI presentation state does not automatically belong in the Model.
If Tooltip visibility is purely a consequence of pointer position or focus, Beverly can manage that lifecycle internally.
If an application has a genuine domain reason to explicitly control contextual information, it can do so through ordinary application state and composition.
Hover and Focus
Tooltips should not depend exclusively on a mouse.
A user navigating with a keyboard should be able to receive the same contextual information when the associated control receives focus.
For an interactive component:
#![allow(unused)]
fn main() {
button().text(localize("user.archive"))
.on("click", Command::User::Archive)
}
The Tooltip is associated with the Button regardless of whether the user reached it through a pointer, keyboard, or another supported input mechanism.
This is particularly important in Beverly because the framework is intended to support serious enterprise interfaces rather than mouse-only desktop interactions.
Context should follow the control, not the input device.
Accessibility
A Tooltip is supplementary information. It should not be the only place where essential information exists.
For example, this is appropriate:
#![allow(unused)]
fn main() {
button().text(localize("user.save"))
}
The Button already has a meaningful label and accessible description.
The Tooltip provides additional contextual information for users who benefit from seeing it.
The Tooltip should not be used to hide an essential label from assistive technology or replace the semantic identity of the control.
Beverly’s accessibility principle remains:
Accessible by construction, not accessible by cleanup.
A Tooltip enhances an accessible component. It should not be responsible for making an otherwise inaccessible component usable.
Localization
Tooltip content should normally use the same localization system as other user-facing text.
#![allow(unused)]
fn main() {
button().text(localize("user.delete"))
}
This keeps the UI’s visible text, accessible semantics, and contextual help within the same localization vocabulary.
The Tooltip should never require developers to hard-code English text simply because it is secondary UI.
Secondary text is still user-facing text.
Data-Driven Tooltips
Tooltips become particularly useful in dense interfaces.
For example, a table may display a shortened identifier while the Tooltip provides the complete value:
#![allow(unused)]
fn main() {
text(document.short_id())
}
Or a Badge may display a compact status while the Tooltip provides additional context:
#![allow(unused)]
fn main() {
badge(document.status())
}
The presentation remains compact while additional information remains available on demand.
This is especially useful for high-density enterprise interfaces, where showing every piece of information permanently would overwhelm the primary interface.
Tooltip and Badge
Badge and Tooltip complement each other but serve different purposes.
#![allow(unused)]
fn main() {
badge(DeploymentStatus::Running)
}
The Badge provides persistent, compact context.
The Tooltip provides additional, temporary context.
Conceptually:
| Primitive | Purpose |
|---|---|
text() | Present information |
badge() | Present compact contextual information |
tooltip() | Provide additional contextual information |
button() | Provide interaction |
The same underlying Model data can drive all of them.
Model Data
├──→ Text
├──→ Badge
└──→ Tooltip
The presentation changes.
The source of truth does not.
Tooltip and Events
A Tooltip normally does not need application Events.
Pointer movement, focus changes, delays, positioning, and visibility are UI concerns.
The Button might emit:
#![allow(unused)]
fn main() {
.on("click", Command::User::Save)
}
The Tooltip does not need to emit an application Command simply because it became visible.
This keeps the Event vocabulary focused on meaningful application behavior rather than every internal UI lifecycle detail.
If Beverly exposes Tooltip lifecycle Events for advanced UI behavior, those should remain clearly distinguished from application Commands.
Not every UI transition is an application Event.
This is an important part of keeping Beverly’s Event system useful.
Composition
Tooltips should compose naturally with ordinary Beverly components.
#![allow(unused)]
fn main() {
card()
.children([
text(Document::title),
badge(Document::status),
])
}
Or:
#![allow(unused)]
fn main() {
row()
.children([
text(User::name),
button().text(localize("user.edit"))
.on("click", Command::User::Edit),
])
}
The Tooltip does not require a special composition model.
It attaches context to existing components.
Add context without adding architecture.
Placement and Presentation
The Tooltip is responsible for presentation concerns such as:
- positioning near its target
- appearing and disappearing appropriately
- handling hover and focus
- avoiding unnecessary clipping
- managing display timing
- rendering the tooltip surface
- adapting to the available viewport
These are UI concerns.
The application should not have to calculate tooltip coordinates or maintain timers merely to explain a control.
This is exactly the kind of complexity Beverly should hide internally while keeping the public API simple.
Magic in implementation. Explicitness at the API.
The developer should be able to say:
without needing to understand how Beverly determines where, when, or how the Tooltip is rendered.
Tooltips Should Stay Small
A Tooltip is designed for concise contextual information.
It should not become a hidden modal, documentation system, or application panel.
If the user needs:
- substantial documentation,
- multiple controls,
- rich interaction,
- persistent information,
- complex content,
another component is probably more appropriate.
For example, use a Modal for substantial temporary interaction:
#![allow(unused)]
fn main() {
modal()
.visible(User::is_help_open)
.children([
text(localize("user.help.title")),
text(localize("user.help.content")),
])
}
The Tooltip remains intentionally small.
A Tooltip answers a small question without interrupting the user’s work.
The Principle
Tooltip demonstrates another Beverly design principle:
Not every piece of UI behavior belongs in application architecture.
A Tooltip may have internal state:
- hovered
- focused
- pending display delay
- visible
- positioned
But none of that necessarily belongs in the application’s Model.
Those are implementation details of presenting contextual information.
The application only needs to provide the context:
Beverly handles the UI mechanics.
This keeps application code focused on things that actually matter to the application.
Why Tooltip Is Simple
A Tooltip should not become:
- a state-management system
- a notification system
- an Event bus
- an application workflow
- a data-fetching system
- a second accessibility system
- a replacement for documentation
It is a small presentation primitive.
The target component owns the interaction.
The application owns the meaning.
The Tooltip provides additional context.
The Tooltip explains. The component acts. The application decides.
That simplicity makes Tooltip predictable for humans, easy for AI systems to generate, and easy to compose throughout an application.
Summary
The Tooltip follows Beverly’s broader architecture:
- Text provides the content.
- Tooltip provides additional contextual presentation.
- The target component remains responsible for interaction.
- Model owns application data.
- Events describe meaningful application activity.
- Controller connects application behavior.
- Beverly manages UI mechanics such as hover, focus, timing, positioning, and visibility.
The common case should remain extremely small:
#![allow(unused)]
fn main() {
button().text(localize("user.save"))
.on("click", Command::User::Save)
}
One line provides additional context. The rest of the application’s architecture remains exactly where it belongs.
Context without complexity.
Actions
Action components report user intent; application systems decide what that intent means.
See Link for the Link component, its LinkClicked message, and application-owned destination handling.
Button
Button construction and behavior are documented on the canonical Button component page.
Link
Live Demo
Open the demo in a separate tab.
Link is an interactive UI component that emits a LinkClicked message when pressed. A link can also carry a route target; when Beverly’s router is installed, a target matching a registered Page changes the current page. Links without a route target remain app-owned actions.
Visible text, accessible description, and route destination are separate values.
Routed Link
Use the link() fluent constructor in a Ui tree. :name segments are replaced by .params(...) values and matched against registered page routes:
#![allow(unused)]
fn main() {
link("View user")
.aria("Opens this user's profile")
.to("/users/:id")
.params([("id", user_id)])
}
The visible label is the text passed to link(). .aria(...) sets the accessible description, .to(...) sets the route target, and .params(...) supplies values for :name path segments. A routed link navigates when its resolved path matches a registered page; query strings on the target become that page’s route query. LinkClicked is still emitted, so application-specific handlers can observe activations too. The active page’s RouteContext exposes the path params and query to its child entities; see Pages, Links, and Layouts.
For page registration, route parameters, and layouts, see Pages, Links, and Layouts.
External Link
Call .external() after .to(...) to open an absolute HTTP(S) target in the system web browser instead of routing it through Beverly:
#![allow(unused)]
fn main() {
link("Visit Beverly")
.to("https://www.beverlyui.com")
.external()
}
External targets still emit LinkClicked. Non-HTTP(S) and invalid targets are ignored with a warning.
Basic Usage
Add Link to a Bevy UI entity that also has Button and Interaction. Create the visual children yourself; the component plugin handles interaction feedback and emits the click message.
#![allow(unused)]
fn main() {
use bevy::prelude::*;
use beverly::components::link::{Link, LinkText};
use beverly::rendering::{Paint, Surface};
fn spawn_link(mut commands: Commands) {
commands
.spawn((
Button,
Interaction::None,
Link::new("Open report"),
Node {
padding: UiRect::all(px(8.0)),
..default()
},
BackgroundColor(Color::NONE),
Surface::rounded_rect_fill(4.0, Paint::solid(Color::NONE)),
))
.with_children(|parent| {
parent.spawn((LinkText, text("Open report")));
});
}
}
BeverlyPlugin installs LinkPlugin, which registers LinkClicked and runs the interaction and visual systems. If you are not using BeverlyPlugin, add LinkPlugin yourself.
Click Messages
The link system watches entities with Link whose Interaction has changed. If the interaction is Pressed and the link is enabled, it writes one LinkClicked message containing the link entity.
UI activation
↓
Interaction changes to Pressed
↓
LinkPlugin checks Link.disabled
↓
LinkClicked { entity }
↓
Application handles the action
Read the message in an application system:
#![allow(unused)]
fn main() {
use bevy::prelude::*;
use beverly::components::link::LinkClicked;
fn handle_link_clicks(mut clicks: MessageReader<LinkClicked>) {
for click in clicks.read() {
info!("Link activated: {:?}", click.entity);
// Handle application-specific actions here; registered route targets
// are handled by Beverly's router as well.
}
}
}
LinkClicked contains only entity; it does not contain a URL or destination. Attach your own route or action data to the entity, or look it up in application state. Message readers only receive messages written after they start reading, so keep the handler registered while links can be activated.
Configuration
Link::new(text) creates the component with the supplied text metadata, no icon metadata, and disabled set to false.
.icon(icon)stores an optional icon name as aString..disabled(disabled)sets whether the link is disabled..aria(description)sets its accessible description..to(path)sets an optional registered route target..params(values)supplies values for:namepath segments.
These are consuming builder methods, so they can be chained:
#![allow(unused)]
fn main() {
let link = Link::new("Documentation")
.icon("book-open")
.disabled(false);
}
The component does not create or update child text or icon nodes from these fields. In the current implementation, text and icon are metadata; create the visible child content explicitly. LinkText can mark a text child for the component’s theme-aware color updates. LinkIcon is available as a marker for application-owned icon children, but the link plugin does not render or update it automatically.
Disabled State
Disabled links do not emit LinkClicked. Their surface becomes transparent and marked LinkText children use the theme’s disabled text color.
#![allow(unused)]
fn main() {
let link = Link::new("Unavailable report").disabled(true);
}
To change the state at runtime, replace the component with an updated value:
#![allow(unused)]
fn main() {
commands
.entity(link_entity)
.insert(Link::new("Open report").disabled(is_unavailable));
}
The component controls its own message and visual behavior. Your application should also ensure disabled links cannot be activated through any separate input or automation path it provides.
Visual States
The built-in visual system uses the theme’s primary, hover, active, and disabled text colors for LinkText children. Links have no border or background fill and show the pointer cursor when hovered.
The link entity needs a Surface for that background feedback. The visual system does not create padding, layout, text, or icon content; those remain part of the entity and its children.
Accessibility
The link’s text supplies its accessible name, and .aria(...) supplies an optional accessible description. Keep the visible label meaningful and preserve a logical keyboard order. Route metadata does not replace either piece of accessibility information.
Dropdown
Live Demo
Open the demo in a separate tab.
Validation Errors
#![allow(unused)]
fn main() {
DropdownConfig::new("Plan")
.option("Free", "free")
.option("Pro", "pro")
.bind(User::plan)
.error(|errors| text(errors.join("\n")))
}
DropdownConfig::error(...) renders the current validation messages below the
control. Bound errors come from the model setter; unbound controls can use
application-owned BindingErrorState. See
Error Callbacks.
Tabs
Live Demo
Open the demo in a separate tab.
Tabs organize several related views within the same area. They are useful for settings, dashboards, or content sections that share a common context and need to switch without leaving the parent surface.
Example
#![allow(unused)]
fn main() {
use beverly::prelude::*;
use beverly::components::tabs::TabsConfig;
app().children([
UiElement::tabs(TabsConfig::new(vec![
Tab::new("overview", "Overview"),
Tab::new("activity", "Activity"),
]))
.header(text("Workspace"))
.body(text("Panel content"))
.footer(button().text("Refresh")),
]).run();
}
Header, Body, and Footer
.header(...), .body(...), and .footer(...) create top, middle, and bottom
regions inside each tab’s content zone, below the tab navigation bar. Each
accepts one element or an iterable; repeated calls append, and section order
does not depend on call order. Ordinary .children(...) content joins the
body before explicit body content. The body grows into available panel height.
The fluent builder supplies the same section content to every tab. For content
that differs per tab, use spawn_tabs(...) or spawn_tabs_in(...) and build
each zone with the callback’s tab index or definition. ContentSection markers
identify the fluent section entities. Existing tab selection, transitions, and
keyboard navigation are unchanged.
Guidance
- use tabs for local navigation, not global app navigation
- keep the number of tabs manageable
- ensure the active tab is visually and semantically clear
- support arrow-key navigation when possible
- keep the content behind the tabs consistent with the selected panel
Accessibility
Tabs should provide a visible selected state, a clear focus order, and labels that make sense independently. Users should be able to understand which panel is active without relying only on color or hover styling.
Theme Toggle
Live Demo
Open the demo in a separate tab.
The theme toggle switches between Beverly’s light and dark themes. The active theme lives in ThemeResource; themed text, icons, and component surfaces follow that resource.
BeverlyPlugin installs ThemeTogglePlugin alongside the button, icon, and theme systems.
Themed Page
spawn_themed_page creates a full-window page with centered column content and an icon-only toggle in the top-right corner.
use beverly::components::text::{TextRole, ThemedText};
use beverly::prelude::*;
use bevy::prelude::*;
fn main() {
App::new()
.add_plugins(DefaultPlugins)
.add_plugins(BeverlyPlugin)
.insert_resource(ThemeResource {
current: theme_from_cli_args(),
})
.add_systems(Startup, setup)
.run();
}
fn setup(mut commands: Commands) {
spawn_themed_page(&mut commands, |content| {
content.spawn((
ThemedText::new(TextRole::Heading),
Text::new("Same biscuits. Different lighting."),
));
});
}
theme_from_cli_args() starts in light mode unless the process has an argument exactly equal to dark.
Standalone Toggle
Use spawn_theme_toggle within an existing UI parent:
#![allow(unused)]
fn main() {
use beverly::components::theme_toggle::spawn_theme_toggle;
use bevy::prelude::*;
fn add_toggle(parent: &mut ChildSpawnerCommands) {
spawn_theme_toggle(parent);
}
}
The helper creates an absolutely positioned wrapper, inset 16 pixels from the parent’s top and right edges. It returns the wrapper entity, not the inner button. The button uses the embedded theme-toggle icon and Beverly’s standard small button style.
This helper does not create a page or synchronize arbitrary background colors. ThemeTogglePlugin updates the background of entities marked ThemedPage and the window’s ClearColor when the theme changes. Other surfaces should use theme-aware components or read ThemeResource themselves.
Custom Button
Reuse toggle_theme when the control belongs in your own toolbar or content layout:
#![allow(unused)]
fn main() {
use beverly::components::button::{BeverlyButton, ButtonChild};
use beverly::components::theme_toggle::toggle_theme;
use bevy::prelude::*;
fn add_shift_button(parent: &mut ChildSpawnerCommands) {
parent.spawn(
BeverlyButton::standard("Switch shift")
.children([
ButtonChild::icon("theme-toggle"),
ButtonChild::text("Switch shift"),
])
.on("click", toggle_theme),
);
}
}
The callback queues a change to ThemeResource. Light mode becomes dark_theme(); dark mode becomes light_theme(). Both controls on the same page share this state.
Theme State
- The stock callback replaces the entire theme with a built-in preset. Custom colors, typography, spacing, or motion tokens are not preserved. Applications with custom light/dark palettes should provide their own callback.
- The initial theme comes from application configuration or
theme_from_cli_args(), not the operating system’s appearance setting. - The toggle does not persist a preference between application launches. Store the chosen mode in application settings if persistence is needed.
- Only theme-aware UI updates automatically. Explicit color overrides remain application-owned.
Example
Run the dedicated demo from the repository root:
cargo run --example theme_toggle
cargo run --example theme_toggle -- dark
The demo includes a custom switch button, a live day/night shift label, and swatches for the page, surface, text, and border colors.
Badge
Live Demo
Open the demo in a separate tab.
A Badge is a compact text primitive used to communicate a short piece of contextual information.
Badges are useful for statuses, categories, counts, labels, states, and other small pieces of information that benefit from visual emphasis.
The important architectural distinction is that a Badge is presentation, not application state. The application decides what the text means and when it changes. The Badge presents that information.
A Badge is text with an opinionated presentation.
Basic Usage
A Badge can be created directly from text:
#![allow(unused)]
fn main() {
badge("Active")
}
For application text, localization should normally be used:
#![allow(unused)]
fn main() {
badge(localize("user.status.active"))
}
Or the Badge can present application data directly:
#![allow(unused)]
fn main() {
badge(User::status)
}
The Model owns User::status. The Badge presents it.
This keeps the responsibility clear:
Model
↓
Application State
↓
Badge
↓
Presentation
The Badge does not decide what the data means. It presents it.
Default Badge Types
Beverly provides a set of default BadgeType values for common cases.
For example:
#![allow(unused)]
fn main() {
badge(BadgeType::Success, localize("user.active"))
}
#![allow(unused)]
fn main() {
badge(BadgeType::Warning, localize("user.pending"))
}
#![allow(unused)]
fn main() {
badge(BadgeType::Error, localize("user.failed"))
}
#![allow(unused)]
fn main() {
badge(BadgeType::Info, localize("user.new"))
}
These types provide a common vocabulary for applications that need familiar contextual treatments.
But they are defaults, not a restriction.
Beverly should not attempt to define every possible kind of badge an application could need.
A healthcare application, for example, may have:
#![allow(unused)]
fn main() {
PatientStatus::Stable
PatientStatus::Monitoring
PatientStatus::Critical
}
A deployment platform might have:
#![allow(unused)]
fn main() {
DeploymentStatus::Building
DeploymentStatus::Running
DeploymentStatus::Failed
DeploymentStatus::RolledBack
}
An enterprise application might have:
#![allow(unused)]
fn main() {
AccountTier::Free
AccountTier::Pro
AccountTier::Enterprise
}
These are different domains with different vocabularies.
Beverly should allow the application to define its own.
Beverly provides a vocabulary. It does not define your domain.
Your Data, Your Type System
The most useful Badge type is often the one that accurately represents the application’s actual data.
For example:
#![allow(unused)]
fn main() {
enum DeploymentStatus {
Building,
Running,
Failed,
RolledBack,
}
}
The application can define how those values should be presented:
#![allow(unused)]
fn main() {
impl BadgePresentation for DeploymentStatus {
fn badge(&self) -> Badge {
match self {
Self::Building =>
badge("Building")
.style(building_style()),
Self::Running =>
badge("Running")
.style(running_style()),
Self::Failed =>
badge("Failed")
.style(failed_style()),
Self::RolledBack =>
badge("Rolled Back")
.style(rolled_back_style()),
}
}
}
}
Then the View can simply express the domain:
#![allow(unused)]
fn main() {
deployment.status().badge()
}
The important part is not the exact API. The architectural principle is:
Your domain types should describe your domain. Beverly should make those types easy to present.
This is preferable to forcing application data into a generic set of UI categories simply because those categories already exist in the framework.
Default Types Are a Starting Point
The default types are useful when they accurately describe what the application needs.
#![allow(unused)]
fn main() {
badge(BadgeType::Success, "Running")
}
is perfectly reasonable when Success is the correct semantic representation.
But an application should not have to pretend that every status is Success, Warning, Error, or Info.
For example, a financial application might need:
#![allow(unused)]
fn main() {
TransactionType::Pending
TransactionType::Settled
TransactionType::Reversed
TransactionType::Disputed
}
A logistics application might need:
#![allow(unused)]
fn main() {
ShipmentStatus::PickedUp
ShipmentStatus::InTransit
ShipmentStatus::Delayed
ShipmentStatus::Delivered
}
Those types carry information that a generic BadgeType cannot.
The framework provides the common case. The application owns the specific case.
Use the default vocabulary when it fits. Define your own vocabulary when it doesn’t.
Type Should Reflect Meaning
A Badge type should describe something meaningful about the data being presented.
For example:
#![allow(unused)]
fn main() {
badge(BadgeType::Warning, "Delayed")
}
communicates a generic presentation.
But:
#![allow(unused)]
fn main() {
badge(ShipmentStatus::Delayed)
}
can communicate a domain concept directly.
That distinction becomes especially valuable as applications become larger and more data-driven.
The application can reason about:
#![allow(unused)]
fn main() {
ShipmentStatus::Delayed
}
instead of passing around arbitrary strings such as:
#![allow(unused)]
fn main() {
"yellow"
"warning"
"delayed"
}
The UI remains strongly typed while the domain remains expressive.
This is one of the places where Rust provides a natural advantage: the application can define precise domain types, and Beverly can turn those types into visual components.
Presentation Does Not Own the Domain
Even when a custom type determines how a Badge looks, the Badge still does not own the underlying state.
For example:
#![allow(unused)]
fn main() {
badge(DeploymentStatus::Failed)
}
does not mean the Badge knows why the deployment failed.
The Model owns the deployment.
The domain type describes its state.
The Badge determines how that state is presented.
Conceptually:
Domain Type
↓
Presentation Mapping
↓
Badge
This keeps the boundary clean.
The Model can be tested without the UI.
The presentation mapping can be tested independently.
The Badge remains a simple reusable component.
Text Is the Primitive
A Badge is fundamentally a form of text.
That means it should inherit the same basic composition philosophy as Beverly’s other text-based primitives rather than introducing another abstraction for content.
A Badge can contain static text:
#![allow(unused)]
fn main() {
badge("New")
}
Localized text:
#![allow(unused)]
fn main() {
badge(localize("document.new"))
}
Or application data:
#![allow(unused)]
fn main() {
badge(Document::status)
}
It can also be composed with other content:
#![allow(unused)]
fn main() {
badge()
.children([
icon("check"),
text(localize("user.active")),
])
}
The Badge does not require a separate content model.
If the primitive is text, use the text system.
Styling
Badges can be configured using Beverly’s fluent styling model.
#![allow(unused)]
fn main() {
badge("Active")
.padding_x(8)
.padding_y(4)
.radius(999)
}
More complete styling can be composed normally:
#![allow(unused)]
fn main() {
badge(localize("user.active"))
.font_size(12)
.font_weight(600)
.padding_x(8)
.padding_y(4)
.radius(999)
}
The API remains familiar:
Create → Configure → Compose
There is no separate styling system required to understand Badge.
Data-Driven Badges
Badges become particularly useful when presenting collections of application data.
For example:
#![allow(unused)]
fn main() {
users.iter().map(|user| {
badge(user.status())
})
}
Or:
#![allow(unused)]
fn main() {
documents.iter().map(|document| {
badge(document.category())
})
}
The Badge remains unaware of where the data came from.
The data could come from SQLite, Parquet, an API, a stream, a data lake, or an AI-generated result.
The Badge only receives the value that should be presented.
This is another consequence of keeping the View thin:
The View presents data. The Model owns data.
Badges Are Not Controls
A Badge is not inherently interactive.
It does not represent a button, input, command, or action.
#![allow(unused)]
fn main() {
badge("Active")
}
is informational.
If the user needs to perform an action, use an interactive primitive:
#![allow(unused)]
fn main() {
button().text(localize("user.manage"))
.on("click", Command::User::Manage)
}
The Badge and Button can be composed:
#![allow(unused)]
fn main() {
row()
.children([
text(User::name),
badge(User::status),
button().text(localize("user.manage"))
.on("click", Command::User::Manage),
])
}
The responsibilities remain separate.
The Badge communicates.
The Button provides interaction.
The Command expresses application action.
Accessibility
A Badge should communicate meaning through more than visual styling alone.
Color, shape, or contrast should not be the only indication of meaning.
For example:
#![allow(unused)]
fn main() {
badge(BadgeType::Error, "Error")
}
contains the semantic word Error rather than relying exclusively on an error color.
When additional accessible context is required, Beverly can expose the appropriate semantic/accessibility configuration.
The general principle remains:
Accessible by construction, not accessible by cleanup.
Badge vs Text
The distinction between text() and badge() should be simple.
#![allow(unused)]
fn main() {
text("Active")
}
means:
Present this text.
#![allow(unused)]
fn main() {
badge("Active")
}
means:
Present this text using Badge presentation.
The content remains text.
The difference is presentation.
A Badge is therefore not a second text system. It is a convenient, opinionated presentation of the existing one.
Badge vs Alert
A Badge and an Alert may both communicate status, but they serve different purposes.
| Primitive | Purpose |
|---|---|
text() | General text |
badge() | Compact contextual text |
alert(type) | Prominent informational/status presentation |
button() | User interaction |
A Badge is appropriate for:
Active
Pending
Beta
New
3
Admin
An Alert is appropriate for communicating something that deserves a larger, more prominent surface:
Your profile was updated successfully.
The distinction is primarily presentation and context.
Neither primitive should become responsible for application state.
The Principle
Badge demonstrates an important Beverly design principle:
Framework defaults should make common cases easy without preventing applications from expressing their own domain.
Beverly can provide:
#![allow(unused)]
fn main() {
BadgeType::Success
BadgeType::Warning
BadgeType::Error
BadgeType::Info
}
because these are useful, familiar defaults.
But an application can define:
#![allow(unused)]
fn main() {
DeploymentStatus
ShipmentStatus
AccountTier
PatientStatus
TransactionState
}
or any other type that accurately represents its own data.
The framework does not need to know what those things mean.
It only needs to make them easy to present.
The framework provides the primitives. Your application provides the vocabulary.
This is particularly important for AI-generated applications. An agent should be able to see:
#![allow(unused)]
fn main() {
DeploymentStatus::RolledBack
}
and understand that it is a domain concept, rather than trying to infer meaning from a generic collection of UI strings and colors.
Strong types make the application’s vocabulary explicit to both humans and machines.
Why Badge Is Simple
There is a temptation to make every UI primitive responsible for state, business logic, events, data fetching, permissions, and application workflows.
Beverly intentionally avoids that.
A Badge does one thing well:
It presents a small piece of information with a recognizable visual treatment.
If the application needs state, the Model owns it.
If the application needs an application action, the Controller connects it.
If the application needs an Event, the Event defines it.
If the application needs a visual representation, the View provides it.
The Badge does not need to know about the rest.
A Badge is not a system. It is a primitive.
That simplicity makes it reusable, predictable, easy for humans to understand, and easy for AI systems to generate correctly.
Summary
The Badge follows Beverly’s broader architecture:
- Text provides the content.
- Badge provides the presentation.
- BadgeType provides useful default presentation vocabulary.
- Application types can define domain-specific badge semantics.
- Model owns the underlying state.
- Events describe application activity.
- Controller connects application behavior.
- Composition determines where the Badge appears.
The common case stays simple:
#![allow(unused)]
fn main() {
badge(BadgeType::Success, localize("user.active"))
}
But the framework never requires the application to stop there.
Your application can define its own vocabulary when the domain demands it.
Use Beverly’s defaults when they fit your data. Define your own types when your data deserves better names.
Divider
Live Demo
Open the demo in a separate tab.
A Divider is a visual separation primitive, similar to the HTML <hr> element.
It creates a visible line between pieces of content and can be oriented horizontally or vertically.
Unlike using a border on another component, a Divider is its own component. That makes its position, size, styling, and animation explicit and independently controllable.
A border decorates a component. A Divider creates a separation.
Basic Usage
Create a horizontal Divider with divider():
#![allow(unused)]
fn main() {
divider()
}
Horizontal is the default orientation.
A vertical Divider can be created explicitly:
#![allow(unused)]
fn main() {
divider()
.vertical()
}
The orientation can therefore be read directly from the code.
Horizontal and Vertical
Horizontal Dividers are useful for separating content vertically:
#![allow(unused)]
fn main() {
column()
.children([
text("Profile"),
divider(),
text("Settings"),
])
}
Vertical Dividers are useful for separating content horizontally:
#![allow(unused)]
fn main() {
row()
.children([
text("Navigation"),
divider().vertical(),
text("Content"),
])
}
The Divider itself does not care what it separates.
The layout determines where the Divider goes. The Divider determines how the separation is presented.
Children
A Divider is intentionally a simple primitive and normally does not contain children.
Its purpose is to create visual separation, not to become a container.
#![allow(unused)]
fn main() {
divider()
}
If content needs to appear alongside a Divider, compose the Divider with the surrounding layout:
#![allow(unused)]
fn main() {
row()
.children([
text("Left"),
divider().vertical(),
text("Right"),
])
}
This keeps the responsibilities clear.
Styling
A Divider can be configured using the same fluent styling model as other Beverly components.
#![allow(unused)]
fn main() {
divider()
.width(200)
}
A vertical Divider can be sized along its horizontal axis:
#![allow(unused)]
fn main() {
divider()
.vertical()
.height(200)
}
Other presentation properties can be configured directly:
#![allow(unused)]
fn main() {
divider()
.width(200)
.thickness(1)
}
The exact dimensions remain part of the component rather than being hidden inside a border on an unrelated component.
Placement
One of the advantages of a Divider as its own primitive is that it can be placed wherever the layout requires it.
#![allow(unused)]
fn main() {
card()
.children([
text("Account"),
divider(),
text("Preferences"),
divider(),
text("Security"),
])
}
A vertical Divider can be placed between controls:
#![allow(unused)]
fn main() {
row()
.children([
button().text(localize("editor.undo")),
divider().vertical(),
button().text(localize("editor.redo")),
])
}
The Divider does not need to belong to either neighboring component.
It is a component in the layout, not a property of the components around it.
Animation
Because the Divider is an independent component, it can be animated directly.
For example, its size can become part of a transition:
#![allow(unused)]
fn main() {
divider()
.width(200)
.animate()
}
Or a Divider can be used as part of a state-driven presentation:
#![allow(unused)]
fn main() {
divider()
.width(200)
.error(|errors| text(errors.join("\n")))
}
The important distinction is architectural: the Divider has its own geometry and presentation, so animation does not require modifying a border belonging to another component.
This makes Dividers useful for more than static separation.
They can become part of transitions, expanding panels, loading states, visual timelines, and other interface effects.
A Divider can move, grow, shrink, fade, and animate because it is an object in the layout.
Data-Driven Dividers
A Divider can also be generated by ordinary Rust when the layout is data-driven.
#![allow(unused)]
fn main() {
fn section_divider() -> impl Component {
divider()
.thickness(1)
}
}
Use it like any other component:
#![allow(unused)]
fn main() {
section_divider()
}
There is no special Divider extension system.
Higher-level components are ordinary Rust functions that return lower-level components.
Accessibility
A purely decorative Divider generally does not need to communicate additional information to assistive technology.
Its purpose is visual separation.
When a Divider represents a meaningful semantic boundary rather than merely a visual line, the surrounding structure should communicate that meaning rather than relying on the line itself.
This keeps the visual primitive simple while allowing the application to provide semantic structure where appropriate.
Not every visual element needs to become an interactive or semantic control.
What Divider Does Not Do
Divider does not:
- own application state
- manage layout state
- contain application data
- define navigation
- emit application Commands
- replace a layout container
- require a separate styling system
It is simply a visual separation primitive.
Divider vs Border
A border is generally part of another component’s presentation:
#![allow(unused)]
fn main() {
card()
.border_width(1)
}
A Divider is its own component:
#![allow(unused)]
fn main() {
divider()
}
Use a border when the boundary belongs to the component itself.
Use a Divider when the separation is an independent element in the layout.
This distinction becomes especially useful when the separation needs its own size, position, animation, or lifecycle.
| Border | Divider |
|---|---|
| Property of another component | Independent component |
| Defines a component boundary | Separates content |
| Primarily decorative | Explicit layout element |
| Harder to animate independently | Can be animated independently |
| Tied to the component’s geometry | Has its own geometry |
Design Principle
The Divider deliberately does one thing well:
Create visual separation as an independent component.
It supports horizontal and vertical orientation, can be positioned anywhere in the layout, and can be styled or animated independently.
There is no need for a larger abstraction.
If a border is enough, use a border. If the separation is part of the layout, use a Divider.
Create. Configure. Compose. Animate when needed.
Pagination
Live Demo
Open the demo in a separate tab.
Pagination helps users move through large result sets or multi-page collections. It is valuable when the data set is too large to display in a single pass but still benefits from clear page boundaries.
Example
#![allow(unused)]
fn main() {
use bevy::prelude::*;
use beverly::prelude::*;
fn build_pager(mut commands: Commands) {
commands.spawn(BeverlyPagination::new(1, 12)
.current_page(4));
}
}
Use when
- a list is too long for a single view
- the user needs to scan through a collection in discrete pages
- content has reasonable page boundaries
- the app benefits from preserving context and avoiding endless scrolling
Guidance
Use pagination to group content into manageable chunks rather than hiding too much information behind a complicated pager. Keep the current page, total page count, and next/previous actions obvious so the user always knows where they are.
Accessibility
The pager should support keyboard interaction and convey state clearly. Users should not need to infer the current page from color alone or from a hidden label that only appears on hover.
List Item
A list item presents a title with an optional subtitle and icon in a full-width
row. BeverlyPlugin installs the visual system, which follows the active theme
and interaction state.
Live Demo
Open the demo in a separate tab.
Basic Usage
#![allow(unused)]
fn main() {
use beverly::components::list_item::spawn_list_item;
use beverly::icons::Icon;
use bevy::prelude::*;
fn add_item(parent: &mut ChildSpawnerCommands) -> Entity {
spawn_list_item(
parent,
"Inbox",
Some("Three unread messages".to_string()),
Some(Icon::feather("mail")),
)
}
}
Pass None to omit the subtitle or icon. Arrange multiple items in a parent
Node with FlexDirection::Column and the desired row gap.
The returned entity carries ListItem and Bevy’s Button marker. Selection,
navigation, and application actions are not assigned by this helper; attach
your own marker and handle interaction in your application.
Table
Keyboard and Accessibility
Table cells and column headers expose semantic roles and contextual names.
Arrow keys move between adjacent cells/headers, Home/End move to the start/end
of the current row, and Enter/Space activate the focused item. Tab enters or
leaves the table through its tab stops. Keyboard and pointer activation emit
the same TableEvent contract. With selectable_rows, activating a cell or row
selects its row, clears the previous selection, and projects selected state.
The table id must be meaningful to the application’s users, not an opaque id.
Real-window focus and screen-reader navigation still require platform testing.
Live Demo
Open the demo in a separate tab.
The Table is Beverly’s primitive for displaying tabular data.
At its core, a Table is simply a two-dimensional collection of values:
Table
├── Row
│ ├── Cell
│ ├── Cell
│ └── Cell
│
├── Row
│ ├── Cell
│ ├── Cell
│ └── Cell
│
└── ...
The API intentionally stays simple.
Rows are represented as an array of values, and the table is therefore a 2D array:
#![allow(unused)]
fn main() {
[
["Name", "Email", "Status"],
["Alice", "[email protected]", "Active"],
["Bob", "[email protected]", "Pending"],
]
}
This makes the primitive easy for both humans and AI systems to construct, inspect, transform, and reason about.
A Table is a two-dimensional array of values.
Basic Usage
A basic table can be created by passing rows:
#![allow(unused)]
fn main() {
table([
["Name", "Email", "Status"],
["Alice", "[email protected]", "Active"],
["Bob", "[email protected]", "Pending"],
])
}
Each inner array represents one row.
Each value within that row represents one cell.
The structure is therefore directly reflected in the code:
[
[cell, cell, cell],
[cell, cell, cell],
[cell, cell, cell],
]
There is no separate row model or table state system required.
Rows
Rows are arrays of values.
#![allow(unused)]
fn main() {
[
["Alice", "[email protected]", "Active"],
["Bob", "[email protected]", "Pending"],
["Charlie", "[email protected]", "Inactive"],
]
}
The first array is the first row.
The second array is the second row.
And so on.
This makes a Table naturally compatible with data that already exists as collections.
For example:
#![allow(unused)]
fn main() {
users.iter()
.map(|user| [
user.name(),
user.email(),
user.status(),
])
}
The Table does not require the application to transform its data into a special Beverly table format.
Use your data. Don’t build a second data model just to display it.
Cells
Every value in a row becomes a cell.
Cells can be styled individually when the application needs more control.
#![allow(unused)]
fn main() {
table()
.cell(0, 0)
.color(red)
}
The coordinates identify the cell:
cell(row, column)
For example:
#![allow(unused)]
fn main() {
table()
.cell(2, 1)
.color(red)
}
targets the cell at row 2, column 1.
The important distinction is that the Table owns the overall structure while the Cell provides the individual presentation.
Cell Styling
Individual cells can be configured independently.
#![allow(unused)]
fn main() {
table()
.cell(1, 2)
.color(red)
}
Multiple properties can be chained:
#![allow(unused)]
fn main() {
table()
.cell(1, 2)
.color(red)
.padding(8)
.radius(4)
}
This makes cell-level styling explicit.
You don’t need a separate stylesheet or selector language to say which cell should look different.
The cell is directly addressable.
Style the cell where you use the cell.
Table Styling
The Table itself can be styled using the same fluent API as other Beverly components.
#![allow(unused)]
fn main() {
table([
["Name", "Email", "Status"],
["Alice", "[email protected]", "Active"],
])
.padding(8)
.radius(12)
}
Table-level styling establishes the presentation of the overall table.
Cell-level styling establishes the presentation of individual cells.
Table styling
↓
Overall presentation
Cell styling
↓
Individual presentation
Headers
A table can represent its header as the first row.
#![allow(unused)]
fn main() {
table([
["Name", "Email", "Status"],
["Alice", "[email protected]", "Active"],
["Bob", "[email protected]", "Pending"],
])
}
This keeps the underlying representation simple.
The application can establish that the first row is a header through table configuration or a higher-level component.
For example:
#![allow(unused)]
fn main() {
table([
["Name", "Email", "Status"],
["Alice", "[email protected]", "Active"],
["Bob", "[email protected]", "Pending"],
])
.header(0)
}
The important point is that headers do not require a separate data structure.
They are still cells in the same two-dimensional representation.
Data-Driven Tables
Tables are particularly useful when the data already exists as a collection.
For example:
#![allow(unused)]
fn main() {
fn user_table(users: &[User]) -> Table {
table(
users.iter()
.map(|user| [
user.name(),
user.email(),
user.status(),
])
.collect()
)
}
}
The resulting component can then be used normally:
#![allow(unused)]
fn main() {
user_table(&users)
}
This is ordinary Rust.
There is no special Beverly data-mapping language required.
Higher-level components are ordinary Rust functions that return Beverly components.
Conditional Cell Presentation
Because cells are independently configurable, applications can present values differently based on their data.
For example:
#![allow(unused)]
fn main() {
for user in users {
let status = user.status();
let mut cell = table.cell(...);
if status == "Active" {
cell = cell.color(green);
}
}
}
The exact implementation can vary depending on how the table is constructed, but the principle remains the same:
Application data determines presentation.
A higher-level component can encapsulate common presentation rules:
#![allow(unused)]
fn main() {
fn status_cell(status: UserStatus) -> Cell {
match status {
UserStatus::Active =>
cell("Active").color(green),
UserStatus::Pending =>
cell("Pending").color(yellow),
UserStatus::Inactive =>
cell("Inactive").color(gray),
}
}
}
Then the table remains simple:
#![allow(unused)]
fn main() {
table([
["Alice", status_cell(UserStatus::Active)],
["Bob", status_cell(UserStatus::Pending)],
])
}
The primitive provides the structure.
The application provides the meaning.
Composition
A cell does not have to be limited to plain text.
Cells can contain Beverly components when the application needs richer presentation.
For example:
#![allow(unused)]
fn main() {
table([
[
text("Alice"),
badge(BadgeType::Success, "Active"),
],
[
text("Bob"),
badge(BadgeType::Warning, "Pending"),
],
])
}
A cell might contain:
- Text
- Badges
- Icons
- Images
- Buttons
- Links
- Other composed components
This allows a Table to remain a primitive while still supporting sophisticated interfaces.
For example, an action cell could contain a Button:
#![allow(unused)]
fn main() {
button().text(localize("user.edit"))
.on("click", Command::User::Edit)
}
The Button remains responsible for interaction.
The Table remains responsible for tabular layout.
Cell Interaction
A Table does not make every cell interactive.
A cell can simply display information:
#![allow(unused)]
fn main() {
cell(text("Alice"))
}
Or it can contain an interactive component:
#![allow(unused)]
fn main() {
cell(
button().text(localize("user.edit"))
.on("click", Command::User::Edit)
)
}
This follows a fundamental Beverly principle:
A container does not become an application just because it contains an interactive component.
The Table provides the layout.
The component inside the Cell provides the interaction.
Accessibility
Tables have semantic meaning, so accessibility should be part of their construction.
A simple table can provide an accessible label and description:
#![allow(unused)]
fn main() {
table([
["Name", "Email", "Status"],
["Alice", "[email protected]", "Active"],
])
.label(localize("users.table.label"))
.aria(localize("users.table.description"))
}
For example:
Label:
Users
ARIA:
List of users with name, email, and account status.
When a table contains interactive controls, those controls remain independently accessible.
For example:
#![allow(unused)]
fn main() {
button().text(localize("user.edit"))
}
The Table’s accessibility describes the table.
The Button’s accessibility describes the Button.
Each component owns its own semantic responsibility.
Accessibility follows composition.
Table State
The Table itself does not need to own application data.
For example, it should not become a second source of truth for users:
Model
↓
Users
↓
Table
↓
Cells
The Table projects application data into a visual representation.
If a user’s status changes in the Model, the table reflects the new value.
The Table does not independently decide what the user’s status is.
This follows the same Model → View relationship used throughout Beverly.
The Table displays the data. The Model owns the data.
Selection
Selection is an application concern rather than an inherent requirement of a Table.
A higher-level table component can provide selection behavior when needed.
For example:
#![allow(unused)]
fn main() {
selectable_table(users)
}
can be implemented as an ordinary Rust function that builds on the Table primitive.
The underlying Table remains simple.
This distinction is important because not every table needs:
- Row selection
- Cell selection
- Sorting
- Filtering
- Pagination
- Virtualization
- Editing
Those can be built as higher-level components when the application requires them.
Tables and Data Grids
A basic Table should not be confused with a full data grid.
A Table provides:
- Two-dimensional data
- Rows
- Cells
- Cell composition
- Cell-level styling
- Table-level presentation
A higher-level data grid may additionally provide:
- Sorting
- Filtering
- Column resizing
- Column pinning
- Row selection
- Cell editing
- Virtualization
- Streaming updates
- Keyboard navigation
- Large dataset optimization
Those capabilities can be layered on top of the Table primitive rather than being required by it.
This keeps the foundational component small while allowing Beverly to build much more sophisticated data interfaces.
Start with the primitive. Add capability when the application needs it.
Large Data Sets
The same two-dimensional representation can serve as the conceptual foundation for much larger tables.
The important distinction is between the data representation and the rendering strategy.
The application can have a large dataset:
Application Data
↓
Rows
↓
Table
↓
Visible Cells
↓
GPU
A higher-level data table can virtualize which rows and cells are actually rendered without changing the application’s conceptual model.
The developer still reasons about rows and cells.
Beverly handles the rendering strategy underneath.
This allows the API to remain simple even when the implementation becomes highly optimized.
Dynamic Updates
Because the Table is a View, it can react to changes in application state.
For example:
#![allow(unused)]
fn main() {
table(users.rows())
}
When the underlying Model changes, the View can produce the corresponding updated table representation.
This means applications can use Tables for:
- Live data
- Monitoring interfaces
- Financial data
- Logs
- AI agent activity
- Device telemetry
- Database results
- Search results
- Administrative interfaces
The Table does not need to know where the data originated.
It only needs a representation of rows and cells.
Custom Table Components
A higher-level table is simply an ordinary Rust function.
For example:
#![allow(unused)]
fn main() {
fn user_table(users: &[User]) -> Table {
table(
users.iter()
.map(|user| [
text(user.name()),
text(user.email()),
badge(user.status()),
])
.collect()
)
}
}
Another application can build a completely different table:
#![allow(unused)]
fn main() {
fn transaction_table(transactions: &[Transaction]) -> Table {
table(
transactions.iter()
.map(|transaction| [
text(transaction.id()),
text(transaction.amount()),
badge(transaction.status()),
])
.collect()
)
}
}
Both are ordinary Rust functions returning a Beverly component.
There is no need to create a new framework abstraction for every domain-specific table.
Build the primitive once. Build domain-specific tables with ordinary Rust.
What Table Does Not Do
The foundational Table does not inherently own:
- Application data
- Database state
- Sorting state
- Filtering state
- Selection state
- Business rules
- Authorization
- Navigation
- Data fetching
- AI behavior
Those concerns belong to the application architecture.
A higher-level component can add any of these capabilities while still using the underlying Table.
Table vs Data Grid
| Table | Data Grid | |
|---|---|---|
| Basic rows and cells | Yes | Yes |
| 2D array representation | Yes | Yes |
| Cell composition | Yes | Yes |
| Cell-level styling | Yes | Yes |
| Sorting | Application / higher-level | Built-in capability |
| Filtering | Application / higher-level | Built-in capability |
| Selection | Application / higher-level | Built-in capability |
| Editing | Application / higher-level | Built-in capability |
| Virtualization | Implementation-dependent | Core capability |
| Large datasets | Foundation | Optimized |
| Streaming data | Foundation | Optimized |
The Table is the primitive.
The Data Grid is a higher-level application component built when the additional behavior is required.
The Principle
The Table deliberately starts with a very small idea:
Rows are arrays. Cells are values. The table is two-dimensional.
Everything else builds from there.
The Table provides the structure.
Cells provide the individual presentation.
Components inside cells provide interaction.
The Model provides the data.
Higher-level components provide specialized behavior.
And ordinary Rust provides the mechanism for composing it all.
A Table is a two-dimensional array of values with composable cells.
Simple enough to understand. Powerful enough to build a data interface on top of.
Design Tokens
Design tokens define the naming and values used across Beverly interfaces. They provide a stable vocabulary for color, spacing, motion, radius, and other visual primitives so that the system can stay consistent as the product grows.
Why tokens matter
A design system becomes difficult to maintain when the same values are redefined repeatedly in different screens and components. Without a shared token layer, a button might use one border radius, a card another, and a panel a different spacing scale. The result is a product that feels visually inconsistent even when each individual element is implemented perfectly.
Design tokens solve this by making the visual decisions explicit and reusable. The system does not depend on one-off values hidden in component implementations; instead, it relies on shared names that map to consistent values.
Core uses of tokens
Design tokens are usually used for:
- color scales and semantic color roles
- spacing and layout rhythm
- border radius and surface shape
- typography sizes and line-height values
- motion durations and easing behavior
- shadow and elevation values
These values are then consumed by components and themes rather than being re-created at each use site.
The value of semantic naming
The most useful tokens are not just raw numbers or strings. They are semantic decisions such as:
- surface.default
- accent.primary
- spacing.lg
- radius.md
- motion.fast
- shadow.panel
This makes the system easier to reason about because the design language maps to intent. A designer or developer can change the underlying value without disrupting the component relationships as long as the semantic role remains stable.
Supporting theme switching
Design tokens are essential for theme support. Beverly can express multiple visual modes—light, dark, high-contrast, or branded variants—by swapping the token values while keeping the same semantic names.
This means the component logic stays stable while the visual language changes. A button still has the same role, but the theme decides whether that role appears as a light or dark treatment, a brighter accent, or a reduced-contrast fallback.
Example token model
#![allow(unused)]
fn main() {
pub enum TokenRole {
Background,
Surface,
Text,
Accent,
Success,
Warning,
Danger,
}
pub struct DesignTokenSet {
pub color: ColorPalette,
pub spacing: SpacingScale,
pub radius: RadiusScale,
pub motion: MotionScale,
}
}
This model makes it easier to reason about the theme as a cohesive system rather than a collection of unrelated visual tweaks.
From tokens to components
A component should read from tokens, not hard-code values. This makes the app easier to evolve and safer to theme. For example, a button’s fill, text color, focus ring, and padding all derive from a small set of shared token decisions rather than being patched individually in each screen.
This has two major advantages:
- visual consistency is maintained by default
- the system can be themed without rewriting component logic
Design token practice
A good token system stays small and intentional. It should clarify the design language without becoming an abstract catalog of every possible value. Beverly works best when the token set is a strong but manageable vocabulary: enough to express the product, but not so large that the system becomes hard to maintain.
Summary
Design tokens are the connective tissue between product intent and implementation. They help Beverly keep its visual system coherent, themeable, and maintainable while preserving a clear separation between structure and style.
Styling DSL
UiElement exposes fluent layout and paint methods. Styles are explicit
overrides, separate from a component’s state, bindings, and semantic behavior.
BeverlyPlugin installs StylingPlugin; custom Bevy apps must install the
styling plugin to keep overrides applied as component visuals change.
Example
#![allow(unused)]
fn main() {
use bevy::prelude::*;
use beverly::prelude::*;
let settings = UiElement::form(Form::new())
.padding(24)
.radius(8)
.gap(16)
.width(400)
.max_width(percent(100))
.background("#ffffff")
.border_width(1)
.border_color("#d4d4d4")
.color("#171717")
.font_size(16)
.header(text("Account settings"))
.body(UiElement::input("Email").label("Email address"))
.footer(text("Changes apply when submitted."));
}
Place the resulting element inside app().children(...), or materialize it
using spawn_in_context. Concrete control builders retain their existing
configuration methods; wrap them in UiElement or call .bind(...) / .error(...)
before applying the shared styling methods.
Methods
| Purpose | Methods | Values |
|---|---|---|
| Padding | padding, padding_x, padding_y, padding_rect | Pixel number, Val, or UiRect for the rect variant |
| Margin | margin, margin_x, margin_y, margin_rect | Pixel number, Val, or UiRect for the rect variant |
| Dimensions | width, height, min_width, max_width, min_height, max_height | Pixel number or Val |
| Gaps | gap, row_gap, column_gap | Pixel number or Val |
| Corners | radius | Nonnegative pixel radius |
| Fill | background | Color, CSS color string, or Paint |
| Border | border_width, border_color | Nonnegative pixel width; Color or CSS color string |
| Typography | color, font_size | Color or CSS color string; nonnegative pixel size |
| Alignment | align_items, align_self, justify_content | Bevy’s corresponding enum |
| Flex sizing | flex_grow, flex_shrink | Nonnegative number |
| Transform | scale, scale_xy | Uniform number or Vec2 |
| Opacity | opacity | Finite number, clamped to 0..=1 |
| Outline | outline_width, outline_color | Nonnegative pixel width; Color or CSS color string |
| Interaction states | hover, focused, pressed, active, inactive, disabled_style | FnOnce(UiStyle) -> UiStyle patch |
| Form status states | submitting_style, success_style | FnOnce(UiStyle) -> UiStyle patch |
Numbers accept ordinary integer and floating-point literals. For example,
.width(400), .width(px(400)), .width(percent(100)), and .width(Val::Auto)
use the same API. .gap(16) sets both axes; follow it with .row_gap(8) to
override only the row gap. Axis-specific spacing preserves the other explicitly
configured axis, and rect variants accept individual side values.
CSS color strings support named colors, hex, rgb(...), and hsl(...) through
csscolorparser. Invalid strings produce a clear construction-time panic;
parse untrusted user input before passing it to the styling builder.
Interaction-state styles are explicit patches built from UiStyle; they do
not use an implicit self component context:
#![allow(unused)]
fn main() {
button()
.text("Save")
.background("#2563eb")
.hover(|style| style.background("#1d4ed8"))
.focused(|style| style.outline_width(2).outline_color("#f59e0b"))
.pressed(|style| style.scale(0.98))
.disabled_style(|style| style.opacity(0.5))
}
For an already-bound form control, the same styles follow checked/focus and app-owned form status:
#![allow(unused)]
fn main() {
CheckboxConfig::new()
.label("Enable alerts")
.bind(User::alerts)
.active(|style| style.color("#15803d"))
.inactive(|style| style.color("#737373"))
.submitting_style(|style| style.opacity(0.6))
.success_style(|style| style.outline_color("#16a34a"))
}
active/inactive read checked, selected, or pressed semantic state.
focused reads Beverly semantic/input focus; hover and pressed use Bevy’s
Interaction. disabled_style reads disabled semantic state, explicit
DisabledInteraction, or the component’s supported disabled property.
submitting_style and success_style read app-owned FormStatusState.
They therefore do nothing until the corresponding state exists on the styled
entity. State precedence is inactive, active, hover, focused, pressed,
submitting/success, then disabled: later active states override only the
properties they set. For example, disabled opacity wins while a disabled
control still happens to be hovered. Boolean configuration remains
.disabled(true); use .disabled_style(|style| ...) for its appearance.
#![allow(unused)]
fn main() {
column()
.padding_x(16)
.padding_y(12)
.margin_rect(UiRect::top(px(8)))
.gap(8)
.align_items(AlignItems::Stretch)
.justify_content(JustifyContent::FlexStart)
.children([
text("Summary").font_size(20).color("rgb(23, 23, 23)"),
button().text("Continue").background(Color::BLACK).radius(6),
])
}
Precedence and Composition
Later assignments to the same property win. Styling can be interleaved with section composition and bound/error wrappers without changing the returned control entity or its form metadata:
#![allow(unused)]
fn main() {
UiElement::input("Email")
.label("Email address")
.bind(User::email)
.error(|errors| text(errors.join("\n")))
.width(320)
.padding(12)
.border_color("#737373")
}
Explicit values are reapplied after component/theme updates and before layout. Unspecified properties retain the component’s own behavior. Parent foreground color/font size styles apply to descendant text and icons; a descendant’s own explicit fluent style wins over inherited styling. Error presentation remains outside the control’s styled content subtree, and its host tracks styled width.
For Modal and Toast, sizing and paint target the visible surface rather than the overlay or notification-positioning lane. For Select and Dropdown, sizing targets the containing control and padding/paint target the trigger. Sections remain in their normal top/middle/bottom order.
Opacity multiplies through ancestors and applies to surface fills/borders, text, icons, and ordinary node backgrounds. It preserves gradient/procedural paint structure and does not compound on successive updates. Arbitrary custom material uniforms and external images are not opacity adapters; configure those materials directly. Semantic focus decorations remain independent from fill/border styling. An outline is an additional static outline, not a replacement for keyboard focus indication.
Scope
This is an explicit styling API, not a CSS parser or selector engine. The
interaction-state methods accept UiStyle patches; the older implicit
.hover(self...)/.focused(self...) spelling is not supported. .success(...)
and .submitting(...) are separate content callbacks; use
.success_style(...) / .submitting_style(...) to change paint or layout.
Style callbacks are static patches, not arbitrary per-frame application
predicates. Opacity and scale are static values, not animations. Clearing an
optional style field stops
enforcing it; it does not restore a captured original value. Assign a concrete
replacement such as Val::Auto or the desired theme paint when resetting a
property. Opacity restores its tracked base alpha when reset to 1.
Opacity and scale do not change disabled state or accessible visibility; use the appropriate control/semantic APIs for those behaviors. Check contrast and focus visibility when overriding theme colors.
Colors
Color in Beverly is composed around semantic roles rather than raw RGB values.
One source of truth
The app should not hand-pick colors in each widget or screen. Instead, Beverly keeps colors in a single theme definition and lets components read from that theme.
That means a dashboard, sidebar, card, alert, button, and form control all inherit the same palette from the root app theme instead of each one inventing its own colors.
This is the central idea behind the design-token system:
- one theme defines the visual language
- semantic color roles describe intent
- components consume those roles instead of hard-coded hex values
If the brand accent changes, or a dark mode palette is updated, the change happens once in the theme and flows through the whole app.
Palette model
The color palette is organized around semantic roles:
- base
- surface
- accent
- success
- warning
- danger
These names describe meaning, not literal colors. For example, accent is the main highlight color for actions and emphasis, while surface is the background of panels and cards. A success state uses the success token instead of a custom green sprinkled across the app.
Example of how it looks
#![allow(unused)]
fn main() {
use bevy::prelude::*;
use beverly::prelude::*;
fn build_shell(mut commands: Commands) {
commands.spawn((
NodeBundle::default(),
BeverlyAppShell::new(),
BeverlyTheme::dark(), // one theme controls all colors in the app
)).with_children(|parent| {
parent.spawn(NodeBundle::default());
parent.spawn(button().text("Deploy"));
parent.spawn(BeverlyAlert::success("Sync complete"));
});
}
}
The important part is that the theme sits at the root of the UI tree. Every child component derives its foreground, background, border, and interaction color from that shared theme.
Conceptual theme structure
This is the idea in simplified form:
#![allow(unused)]
fn main() {
// Conceptual example; exact API may vary by implementation.
let theme = Theme {
colors: {
base: "#0b1120",
surface: "#111827",
accent: "#7c3aed",
success: "#22c55e",
warning: "#f59e0b",
danger: "#ef4444",
},
};
}
In other words, the whole application has a single palette source, and all visual components inherit from it. That keeps the UI cohesive, easier to theme, and much easier to maintain over time.
Usage
Use semantic color roles for states instead of direct color literals in app code.
That keeps components consistent across light, dark, and custom branded themes while preserving a clear accessibility story.
Checking WCAG Contrast
For solid colors, beverly::theme exposes relative_luminance,
contrast_ratio, and meets_contrast, along with WCAG 2.2 threshold
constants. Use these helpers to check text and meaningful UI indicators:
#![allow(unused)]
fn main() {
use bevy::prelude::Color;
use beverly::theme::{
contrast_ratio, meets_contrast, WCAG_AA_NORMAL_TEXT, WCAG_AA_UI_COMPONENT,
};
let foreground = Color::WHITE;
let background = Color::srgb(0.12, 0.18, 0.24);
let ratio = contrast_ratio(foreground, background);
let readable_text = meets_contrast(foreground, background, WCAG_AA_NORMAL_TEXT);
let clear_control = meets_contrast(foreground, background, WCAG_AA_UI_COMPONENT);
}
The thresholds are 4.5:1 for normal text at AA, 3:1 for large text at AA,
7:1 and 4.5:1 for normal and large text at AAA, and 3:1 for meaningful
non-text UI components at AA. The helpers treat both inputs as opaque solid
colors. For gradients, translucent surfaces, or blur, first determine the
effective background that needs to meet the threshold; checking only one
nominal token cannot guarantee contrast over changing content.
Typography
Typography in Beverly is designed to support clear reading, strong hierarchy, and high readability in dense dashboards and AI-native tools.
Global font
Set one font at the application root so every text style inherits the same family. That keeps the app visually consistent and makes brand updates easy to apply in one place.
In practice, you define the font once in your theme or app shell, then let titles, labels, and body text read from that shared setting.
#![allow(unused)]
fn main() {
// Conceptual example; exact API may vary by implementation.
let theme = Theme {
typography: Typography {
font_family: "Inter",
..default()
},
..default()
};
}
Title
Use title text for the main message of a screen, card, or panel. Titles should be short, prominent, and easy to scan.
Titles usually sit above supporting copy and should carry the strongest size and weight in the local hierarchy.
Subtitle
Use subtitle text for secondary context that supports the title without competing with it. Subtitles are useful for short descriptions, metadata, or explanatory lines.
Subtitles should remain visually lighter than the title while still being readable at a glance.
Adding text
Text in Beverly works like the rest of the UI: you add it to the app tree as part of a parent layout, and it inherits the active theme and typography rules.
That means you can place text inside headers, cards, sidebars, or any other surface without inventing a separate text system for each screen. The title and subtitle styles are just two common ways to apply that same text pipeline.
#![allow(unused)]
fn main() {
use bevy::prelude::*;
use beverly::prelude::*;
fn build_screen() {
app().children([
text("Workspace"),
text("Last synced 2 minutes ago"),
text("Everything below this point uses the same global font."),
]).run();
}
}
Text colors
Text color should come from the active theme instead of being picked ad hoc in each widget. Use the default foreground color for normal copy, a muted foreground for supporting text, and a stronger emphasis color only when the text itself carries interactive or semantic weight.
This keeps titles, subtitles, and body copy readable across light, dark, and branded themes. It also ensures text still contrasts correctly when the app switches to high-contrast or reduced-contrast presentations.
In other words, typography decides the structure of the text, while the theme decides the actual color values that flow through it.
Changing size
Change text size by choosing a different type scale or by overriding the size on a specific text element when a screen needs emphasis.
Use the larger styles for page-level headers and the smaller styles for dense metadata, labels, and helper copy. Keep size changes deliberate so the hierarchy stays readable.
Scale
- Display
- Heading
- Body
- Label
- Caption
The scale gives you a predictable set of steps instead of arbitrary one-off font sizes. That makes it easier to keep the interface balanced when you resize text for different surfaces or screen densities.
Spacing
Beverly uses a consistent spacing scale to keep layout rhythm predictable. Spacing is not merely a cosmetic detail; it is one of the primary ways the system communicates hierarchy, state, and readability. A well-spaced interface feels calmer, more intentional, and easier to trust because the user can quickly understand which elements belong together and which ones are separate.
Why spacing matters
Spacing shapes the interface in several important ways:
- it separates groups of content that should be read together from groups that should be read separately
- it makes dense surfaces easier to scan without turning them into a wall of text or controls
- it supports alignment and rhythm across cards, forms, toolbars, and navigation surfaces
- it creates a sense of breathing room that keeps the interface from feeling overloaded
Without a clear scale, every screen risks becoming inconsistent: one card has too much padding, another too little, a toolbar feels cramped, and section boundaries become ambiguous. A shared spacing system keeps those decisions stable and scalable.
Core principle
Spacing should be semantic and regular, not arbitrary. Beverly uses a small set of spacing tokens so component layout decisions remain consistent across the whole app.
The default scale is usually something like:
- xs
- sm
- md
- lg
- xl
- 2xl
The smaller tokens are used for compact controls, list items, and tight layout groups. The larger tokens are used for major section transitions, page-level rhythm, and surfaces that need more breathing room.
The important idea is that a token such as md should mean the same visual rhythm regardless of where it appears: inside a card, between form fields, between buttons in a toolbar, or between panels on a page.
How spacing functions in the system
Think of the spacing scale as a shared vocabulary rather than a set of arbitrary pixel values. Components and layouts ask for a spacing token, and the theme or design system resolves that token into the actual distance.
That gives you several benefits:
- layouts stay predictable across the app
- components can be composed without constantly re-tuning spacing
- visual density can be adjusted globally by changing the scale implementation instead of editing every screen
- consistent rhythm helps a product feel mature and designed rather than improvised
Example: page section spacing
#![allow(unused)]
fn main() {
use bevy::prelude::*;
use beverly::prelude::*;
fn build_page() {
app()
.children([text("Overview"), text("Key metrics and activity appear below.")])
.run();
}
}
Here, the larger spacing separates the title block from the content area, while the content itself can use tighter spacing inside the grid. This preserves a strong sense of hierarchy without forcing every surface to have the same density.
Example: card content
#![allow(unused)]
fn main() {
fn build_card(mut commands: Commands) {
commands.spawn(BeverlyCard::new("Deployment status")).with_children(|card| {
card.spawn(text("All services are healthy."));
card.spawn(text("Last updated 2 minutes ago"));
});
}
}
In a card, spacing usually controls the inset around the content and the vertical distance between the title, body text, and footer details. A card that is too dense may become hard to read; a card with excessive padding may feel disconnected from the rest of the screen.
Example: dense controls
#![allow(unused)]
fn main() {
fn build_toolbar(mut commands: Commands) {
commands.spawn(BeverlyStack::horizontal())
.with_gap(Spacing::sm)
.with_children(|toolbar| {
toolbar.spawn(button().text("Refresh"));
toolbar.spawn(button().text("Export"));
toolbar.spawn(button().text("Share"));
});
}
}
Compact controls work best with tighter spacing so the toolbar stays efficient without feeling crowded. This is an example of using the spacing scale deliberately: the same rules that create hierarchy on a page also support quick action groups in a toolbar.
Layout strategy
Beverly’s spacing system works best when it is used with the same intention across the product:
- use tighter spacing inside related component groups
- use wider spacing between independent sections
- keep the relationship between neighboring elements clear and intentional
- avoid mixing arbitrary gaps that create an inconsistent rhythm
This keeps the app visually coherent and easier to maintain as more screens are added.
Summary
Spacing is a foundational design tool because it creates rhythm, hierarchy, and calm. In a product system, the spacing scale is part of the interface’s logic, not just its aesthetic finish. When used consistently, it helps the whole app feel unified and easier to read.
Borders
Borders define structure, separation, and interaction emphasis. They provide a clean visual edge without needing to add heavy fills or extra decoration.
In Beverly, borders are not just decorative lines. They carry meaning: a neutral border can separate surfaces, a stronger border can signal focus or active state, and a high-contrast outline can call attention to an interactive control.
Border roles
Use borders according to their intent:
- subtle separators for low-importance boundaries
- focus rings for keyboard or pointer emphasis
- emphasis borders for selected or highlighted content
- input outlines for form controls and editable fields
The rule is simple: the border should support the content, not compete with it. A dense dashboard can use subtle edges almost everywhere, while an active state or critical panel can move to a stronger visual treatment.
Scale
Border weight should be consistent across the app. A thin stroke works for separators and form outlines, while a heavier stroke works for focus or selected surfaces.
A useful scale is:
- thin
- regular
- strong
- focus
Thin borders keep layouts clean and calm. Stronger borders appear when the UI needs to show intent, focus, or hierarchy.
When to use each one
Subtle separators
Use subtle borders to separate areas that belong together but still need a visual edge. This is common in cards, sidebars, tables, and stacked panels.
Focus rings
Use focus rings when a control is active, selected, or keyboard-focused. The border should be obvious enough to stand out but still remain part of the surrounding layout.
Emphasis borders
Use emphasis borders when a panel, table row, or card needs to feel important without turning into a large highlighted block. This is a good way to show selection or state without overusing color alone.
Input outlines
Inputs are one of the most common cases for borders. A standard outline communicates editability and structure, while a stronger outline signals validation, active state, or keyboard focus.
Example: form field
#![allow(unused)]
fn main() {
use bevy::prelude::*;
use beverly::prelude::*;
fn build_form(mut commands: Commands) {
commands.spawn(BeverlyCard::new("Project details")).with_children(|card| {
card.spawn(BeverlyInput::new("Project name"));
card.spawn(BeverlyInput::new("Owner"));
card.spawn(button().text("Save changes"));
});
}
}
In a form, the border is doing a quiet but important job: it defines the control boundary, keeps the field readable, and reinforces focus when the user interacts with it.
Example: selection state
#![allow(unused)]
fn main() {
fn build_dashboard(mut commands: Commands) {
commands.spawn(BeverlyList::new()).with_children(|list| {
list.spawn(BeverlyListItem::new("Overview").selected());
list.spawn(BeverlyListItem::new("Deployments"));
list.spawn(BeverlyListItem::new("Activity"));
});
}
}
A selected item can use a stronger border or a more visible edge treatment to signal that it is the current context. This keeps the selection easy to notice without forcing the whole row to become a loud accent block.
Theme consistency
Border color should come from the active theme, not from arbitrary hard-coded values. That means the same border semantics can hold across light mode, dark mode, and custom branded variants.
A strong border in one theme should feel equivalent to a strong border in another, even if the specific color values differ. The semantics remain stable; the implementation adapts to the palette.
Rule of thumb
Keep borders intentional and restrained:
- separate when needed
- emphasize when the state matters
- focus when the user is interacting
- avoid using borders as decoration alone
When border treatment is consistent, the interface looks more cohesive and feels easier to scan.
Shadows
Shadows communicate depth and focus without overpowering interface content. Beverly uses a small set of drop-shadow presets so surfaces feel elevated consistently instead of feeling arbitrarily heavy or soft.
Drop shadows
Use drop shadows to separate a surface from the background when it should feel like it sits on top of the page. They are especially useful for cards, floating toolbars, popovers, and other elements that need a clear sense of elevation.
Small
Use the small shadow for subtle lift. This is the right choice for default cards, compact buttons, hover feedback, and other places where the interface should feel light and refined.
Small shadows should be gentle enough that they do not compete with the content itself. The effect is mostly about orienting the eye, not announcing the surface.
Regular
Use the regular shadow for the default elevated surface. This is the most common shadow level for panels, card groups, inline popovers, and other important but not dramatic surfaces.
It gives enough separation to read as a distinct layer without becoming obvious or decorative. In most layouts, this is the standard choice.
Large
Use the large shadow for major floating surfaces such as modals, drawers, high-priority panels, and other UI that needs strong separation from the main page. This shadow is more visible because the surface is farther from the background and needs more emphasis.
Large shadows should be used selectively. If every surface uses a large shadow, the interface loses hierarchy and starts to feel noisy.
Rule of thumb
- use the smallest shadow that carries the needed elevation
- keep all shadows in the same system so the app feels coherent
- reserve the largest shadow for decisive, high-priority overlays
- avoid stacking multiple heavy shadows on a single element
Example
#![allow(unused)]
fn main() {
commands.spawn(BeverlyCard::new("Overview")
.with_shadow(ShadowPreset::Small));
commands.spawn(BeverlyCard::new("Metrics")
.with_shadow(ShadowPreset::Medium));
commands.spawn(BeverlyModal::new("Settings")
.with_shadow(ShadowPreset::Large));
}
The small, regular, and large presets are not arbitrary sizes; they define a shared vocabulary for elevation across the interface.
Motion
Motion is one of the clearest ways to communicate change in a UI, but it should never become the primary message. Beverly treats motion as a supportive layer: it reinforces structure, confirms state, and helps users understand transitions without distracting them from their task.
Principles
- Use motion to reinforce state changes, not to replace clarity
- Keep movement brief, direct, and predictable
- Preserve continuity when the interface changes
- Respect
prefers-reduced-motionwithout sacrificing comprehension - Favor system-level consistency over isolated decorative effects
What motion should do
Motion is useful when it answers a simple question for the user:
- What changed?
- Where did focus go?
- Is this action still running?
- Is the content moving in a known direction?
A good motion pattern makes the interface easier to track. A weak one adds noise and increases cognitive load.
Timing and scale
The motion system should remain compact and consistent across the product. Beverly keeps motion within a narrow timing band so transitions feel intentional rather than dramatic.
- micro-interactions: 120-180 ms
- short state changes: 180-260 ms
- medium layout updates: 260-400 ms
- longer flows: only when there is a real need to show progress or context
Longer durations should be the exception, not the default. If an interaction takes more than a few hundred milliseconds, it should usually be paired with clear status text or loading feedback.
Examples of how to add motion
The exact API names may vary by implementation, but the pattern should stay consistent: motion should be attached to a semantic state change, use a short, controlled duration, and fall back cleanly when reduced motion is active.
Example: animated panel reveal
#![allow(unused)]
fn main() {
use bevy::prelude::*;
use std::time::Duration;
fn build_expanding_panel(mut commands: Commands) {
commands.spawn((
NodeBundle::default(),
BeverlyPanel::new()
.with_transition(Transition::slide_in(
Duration::from_millis(220),
Easing::standard(),
))
.with_initial_state(PanelState::Collapsed),
));
}
}
This keeps the change tied to a real state transition. The panel opens with a short slide or fade, and the motion supports the user’s understanding of the control rather than acting as a decorative flourish.
Example: button feedback
#![allow(unused)]
fn main() {
use bevy::prelude::*;
fn handle_press(mut button: Query<(&mut Transform, &Interaction), Changed<Interaction>>) {
for (mut transform, interaction) in &mut button {
if *interaction == Interaction::Pressed {
transform.scale = Vec3::splat(0.98);
} else {
transform.scale = Vec3::ONE;
}
}
}
}
This type of micro-interaction should feel immediate but subtle. The component should not wobble, overshoot, or distract from the action itself.
Example: reduced-motion-aware transition
#![allow(unused)]
fn main() {
use bevy::prelude::*;
fn transition_duration(reduced_motion: Res<ReducedMotion>) -> Duration {
if reduced_motion.enabled() {
Duration::ZERO
} else {
Duration::from_millis(220)
}
}
}
This is the key rule: when motion is reduced, remove or shorten the non-essential animation while keeping the state change itself clear and accessible.
State transitions
Transitions are the most common use of motion in Beverly. They should make state changes feel natural and legible without stealing attention.
Examples include:
- panels expanding and collapsing
- tabs or sections switching
- filtered lists updating
- dialog or drawer entrances and exits
- content appearing as the user changes context
In these cases, motion should support continuity. The user should feel that the interface is responding coherently, not that a new visual layer has appeared on top of the old one.
Feedback and micro-interactions
Small interaction feedback is useful when it confirms a user action immediately. Examples include button press states, selection highlights, active-state pulses, and subtle confirmation motion on successful actions.
The key is restraint. Motion should be noticeable enough to feel responsive, but too small to become a performance or distraction problem. If the action is already obvious from labels, state, and color, extra motion may be unnecessary.
Loading and progress
Loading movement should reassure the user that work is happening while preserving orientation. Beverly prefers lightweight, informative progress patterns over oversized or flashy effects.
Good loading states:
- maintain layout stability
- communicate progress or activity accurately
- avoid making the UI feel unstable or visually noisy
- remain readable in reduced motion modes
When exact progress is unknown, motion should communicate activity without implying false precision. A quiet pulse or placeholder skeleton is usually more reliable than an over-animated indicator.
Reduced motion
Reduced motion is a core accessibility requirement, not a secondary option. Beverly should respect prefers-reduced-motion by decreasing or removing non-essential animation while preserving semantic clarity.
When motion is reduced:
- decorative loops should be suppressed
- non-critical transitions can become immediate
- status still needs to remain clear through text, structure, and contrast
- content should not lose context during layout changes
The goal is not to make the interface static; it is to keep it understandable and calm.
Motion checklist
Before shipping any motion pattern, ask:
- Does it clarify state or hierarchy?
- Is it short enough to feel responsive?
- Does it still make sense without animation?
- Does it respect reduced-motion preferences?
- Does it help the user understand the next action, not just look polished?
If motion cannot answer those questions, the safer default is a simple state change with strong structure and clear labels.
Summary
Beverly’s motion language should feel quiet, intentional, and trustworthy. Motion is a tool for guidance, not spectacle. When used well, it helps people understand the interface at a glance while keeping the system calm, accessible, and efficient.
Themes
Themes give an application one shared visual language. Beverly’s built-in themes are light and dark; each provides semantic colors and shared design values while leaving component structure and behavior unchanged.
The active theme is a global Bevy resource, not a component attached to a root UI node. Beverly components read from that resource, so changing it updates the theme values they use throughout the interface.
The Global Theme
Beverly’s BeverlyPlugin installs ThemePlugin, which initializes ThemeResource with light_theme(). To start in dark mode, insert a resource containing dark_theme() after adding BeverlyPlugin:
use bevy::prelude::*;
use beverly::prelude::{BeverlyPlugin, ThemeResource, dark_theme};
fn main() {
App::new()
.add_plugins(DefaultPlugins)
.add_plugins(BeverlyPlugin)
.insert_resource(ThemeResource {
current: dark_theme(),
})
.run();
}
This is application-wide configuration: cards, buttons, text, form controls, navigation, and other theme-aware Beverly components all use the same active palette. There is no need to set a separate theme on each screen or widget.
Switching Light And Dark
Send a ThemeChanged message to switch modes at runtime. The theme plugin replaces the current theme with the corresponding built-in preset, and components that read ThemeResource use the new values:
#![allow(unused)]
fn main() {
use bevy::prelude::*;
use beverly::prelude::ThemeMode;
use beverly::theme::ThemeChanged;
fn switch_to_dark(mut theme_changes: MessageWriter<ThemeChanged>) {
theme_changes.write(ThemeChanged {
mode: ThemeMode::Dark,
});
}
}
ThemeMode currently has Light and Dark variants. High-contrast and reduced-effects policies are configured separately through Beverly’s accessibility APIs; they are not additional built-in ThemeMode values.
Why This Feels Cohesive
The presets keep semantic roles stable while changing their values. For example, components use background, surface, text, text_muted, primary, border, and focus rather than each choosing unrelated colors. Light and dark themes provide different colors for those same roles, preserving hierarchy and intent as the whole application changes appearance.
Theme also groups shared typography sizes, spacing, corner radii, border widths, shadows, visual-effect defaults, and transitions. Components can therefore share more than a palette, and a product can tune its design language centrally instead of accumulating one-off values in each screen.
Use semantic component variants for intent (such as success or danger) and reserve direct token customization for genuine product-specific needs. Avoid hard-coding colors for ordinary component states: local literals can break contrast or make one widget feel disconnected when the global mode changes.
Customizing The Presets
The current theme can be customized as a resource. This example changes the primary color while retaining the rest of the light preset:
#![allow(unused)]
fn main() {
use bevy::prelude::*;
use beverly::prelude::{ThemeResource, light_theme};
fn set_brand_color(mut theme: ResMut<ThemeResource>) {
theme.current.colors.primary = Color::srgb(0.12, 0.38, 0.72);
}
}
Apply custom values after a mode change if they should persist: sending ThemeChanged selects a fresh built-in light or dark preset. Also note that not every color is mode-dependent. Tokens such as light_surface and dark_surface intentionally remain fixed because they represent component variants, not the app’s current appearance.
Accessibility
Accessibility is not a bolt-on layer for a completed interface. It is a design constraint that shapes the structure, behavior, and visual system of every component. In Beverly, accessibility should be part of the default contract: semantic structure, keyboard reachability, stable focus, and legible states are expected before visual polish is considered complete.
Why it matters
An interface that looks polished but is hard to operate creates friction for every user. People with motor, visual, cognitive, or attention-based differences often feel the impact most strongly, but accessibility gains benefit everyone:
- keyboard users can operate dense workspaces without a pointer
- low-vision users keep context when contrast and focus are clear
- users with sensory sensitivity benefit from reduced motion and lower visual noise
- screen-reader users understand structure and state without relying on color alone
Accessibility therefore affects product quality, not just compliance. It reduces mistakes, improves confidence, and makes interfaces usable in more contexts.
Accessibility as system design
The best accessibility model is built into the component architecture. Beverly should prefer patterns that make semantics obvious and behavior predictable by default:
- controls should have names and roles
- state changes should be visible and understandable
- focus order should match the logical reading flow
- motion should support reduced-motion preferences
- labels and hierarchy should survive theme changes and visual enhancement layers
This is especially important in dynamic Bevy UI where layouts, panels, overlays, and animated containers can change rapidly. A robust system treats accessibility as a property of composition, not as a late-stage QA checklist.
Semantic structure
Accessibility begins with the structure of the interface, not its final color palette.
A well-structured UI should make these questions answerable without guessing:
- What is this control for?
- What happens when the user activates it?
- What state is this component in?
- Where am I in the interface?
- What changed after the last action?
In practical terms, this means:
- use clear component hierarchy for cards, panels, nav items, forms, and dialogs
- label actions explicitly instead of relying on visual context alone
- expose state such as selected, disabled, expanded, loading, or error
- keep content organized in a readable and predictable order
Example: semantic card + action layout
#![allow(unused)]
fn main() {
use bevy::prelude::*;
use beverly::prelude::*;
fn build_summary_card(mut commands: Commands) {
commands.spawn((
NodeBundle::default(),
BeverlyCard::new("Team overview"),
)).with_children(|card| {
card.spawn(text("12 active tasks and 3 alerts pending"));
card.spawn(BeverlyButton::secondary("View details"));
});
}
}
The card has a clear heading, supporting description, and explicit action label. A screen reader can interpret the layout without needing to infer intent from color or placement alone.
A semantically clear interface makes both assistive technologies and users more confident in how the app works.
Keyboard and pointer parity
Keyboard support is not optional. If a control is important enough to appear in the UI, it should usually be operable without a mouse.
The baseline behavior should include:
- focusable interactive controls
- a logical tab order matching the layout
- visible focus styling at every interactive step
- Enter and Space support for buttons and action triggers
- Escape support for dismissing transient interfaces
- arrow-key behavior for menus, lists, and tab-like navigation
Example: keyboard-first action bar
#![allow(unused)]
fn main() {
use bevy::prelude::*;
use beverly::prelude::*;
fn build_action_bar(mut commands: Commands) {
commands.spawn((
NodeBundle::default(),
BeverlyToolbar::new(),
)).with_children(|toolbar| {
toolbar.spawn(BeverlyButton::primary("Save changes"));
toolbar.spawn(BeverlyButton::secondary("Discard"));
toolbar.spawn(BeverlyButton::subtle("More actions"));
});
}
}
The actions are explicit, tabbable, and consistent. Users should be able to reach them in order without needing a pointer or hover-based affordance.
This creates parity between pointer and keyboard interaction. A screen with a nice hover state but no keyboard path is incomplete, because the interface fails for people who do not use a pointer and for anyone working in a keyboard-first workflow.
Focus management
Focus is the map that lets a user understand where they are in an interface. When focus is invisible, unstable, or hard to recover, the application becomes disorienting.
A strong focus model does the following:
- clearly reveals which element is active
- keeps focus consistent while content updates
- moves into dialogs or drawers when they open
- restores focus to the triggering element when a dialog closes
- preserves context when filters or lists update
Example: dialog focus handoff
#![allow(unused)]
fn main() {
use bevy::prelude::*;
use beverly::prelude::*;
fn open_confirmation_dialog(mut commands: Commands, mut focus_state: FocusState) {
let trigger = focus_state.current();
commands.spawn(BeverlyModal::new("Archive project")
.body("This will move the project to archive status.")
.confirm_label("Archive")
.cancel_label("Cancel")
.on_close(move |world| {
if let Some(element) = trigger {
world.focus(element);
}
}));
}
}
This pattern keeps the user in context. When the modal closes, focus returns to the element that opened it rather than vanishing or jumping to the top of the app.
Focus should never be treated as a visual flourish alone. It is a critical accessibility affordance and an operational necessity for keyboard users.
Contrast and legibility
Strong legibility matters for all users but especially for people with low vision or glare sensitivity. Beverly should favor reliable contrast and meaningful separation over subtle styling tricks.
The system should ensure:
- text remains readable against its background
- important states are identifiable without relying on color alone
- focus rings remain visible in light and dark themes
- disabled content remains distinguishable without becoming unreadable
- panels, borders, and controls do not disappear into translucent surfaces
Example: status styles that do not rely on color alone
#![allow(unused)]
fn main() {
use bevy::prelude::*;
use beverly::prelude::*;
fn build_status_surface(mut commands: Commands) {
commands.spawn((
NodeBundle::default(),
BeverlyAlert::new("Sync failed")
.variant(AlertVariant::Error)
.with_icon("warning")
.with_detail("Retry the upload to continue."),
));
}
}
The alert combines text, icon, and semantic state so the failure is understandable even when the user cannot rely on color distinctions alone.
Color is useful, but it should not be the only signal. A selected state can include a fill change, border emphasis, and text treatment together so the difference is clear under many viewing conditions.
Motion and reduced motion
Animation can improve clarity, but it should not be required for understanding. Reduced-motion preferences are a key accessibility requirement because motion can trigger discomfort or distraction.
Beverly should treat reduced motion as a system-level policy:
- remove or soften decorative animation when the user prefers reduced motion
- keep essential state changes readable without flair
- preserve focus movement and semantic clarity
- avoid animation-only feedback that hides meaning or delays confirmation
Example: motion-aware transition policy
#![allow(unused)]
fn main() {
use bevy::prelude::*;
use beverly::prelude::*;
fn spawn_loading_state(mut commands: Commands) {
let prefers_reduced_motion = true;
commands.spawn((
NodeBundle::default(),
BeverlyStatus::new("Uploading"),
)).with_children(|status| {
status.spawn(text("72% complete"));
if prefers_reduced_motion {
status.insert(TransitionPolicy::Instant);
} else {
status.insert(TransitionPolicy::Fade);
}
});
}
}
This approach keeps progress visible even when motion is reduced. The interface still communicates progress without relying on a decorative animation to signal state.
A user should never have to guess whether a state changed if the motion has been suppressed. The system should still communicate success, error, loading, and selection clearly through text, structure, and visual state.
Screen-reader support
Screen readers rely on semantics and naming, not just visual cues. For an interface to be understandable by assistive technology, every interactive element should expose the right role and label.
This means:
- buttons and actions need clear labels
- form controls need descriptive names and constraints
- tabs, menus, and dialogs need meaningful structure
- status surfaces need readable text that announces updates
- repeated elements should not be ambiguously named
Example: labeled form field and action
#![allow(unused)]
fn main() {
use bevy::prelude::*;
use beverly::prelude::*;
fn build_named_form(mut commands: Commands) {
commands.spawn((
NodeBundle::default(),
BeverlyStack::vertical(),
)).with_children(|parent| {
parent.spawn(BeverlyInput::new("Project name")
.placeholder("internal-tools")
.required(true));
parent.spawn(BeverlyButton::primary("Create project"));
});
}
}
The input label provides a screen-reader name, and the action button explains exactly what it does. This keeps the form understandable even when the visual context is reduced.
A visually impressive UI is not accessible if the user cannot tell what each control does. The best accessible products do not hide critical meaning behind layout alone.
Error, warning, and status communication
Users need to understand state changes even when they are not looking directly at the most recent interaction. Error messaging, busy states, validation states, and progress indicators should be clear and discoverable.
A good status system combines:
- explicit labels or text
- meaningful hierarchy
- visible state treatment
- predictable announcement patterns for assistive technologies
For example, an error should not depend only on a red border or an icon. It should also include explanatory text and a clear path to recovery.
Accessible interaction patterns
Accessibility is not just about a correct HTML or Bevy role assignment. It is also about the interaction model the user experiences as they move through the interface.
A good accessibility pattern makes several things true at the same time:
- interactive controls are reachable by pointer and keyboard
- state changes are communicated in a way users can perceive
- layout and focus remain stable while content updates
- important actions stay visible without requiring complex visual memory
- users can recover from mistakes without losing their place
This matters because accessibility is often shaped by the sequence of interactions, not only the final appearance. A screen may look polished but still be frustrating if the user cannot determine what happens next, where focus went, or whether a prior action succeeded.
Predictable action patterns
Beverly should encourage predictable action patterns across all components:
- confirmation before irreversible actions
- visible progress during long-running tasks
- clear error states with specific recovery paths
- actions that have obvious names and outcomes
- consistent patterns for opening, dismissing, and returning from dialogs and side panels
The goal is not to make every control feel identical. The goal is to ensure that when a user interacts with an element, the interface responds in a way that is obvious, stable, and recoverable.
Recoverability and error guidance
Accessible interfaces are forgiving. When something goes wrong, the user should understand what happened and what to do next.
This means:
- show errors near the relevant field or action
- preserve entered information when a validation failure occurs
- make destructive actions explicit and reversible when possible
- keep focus on the failed control or the nearest relevant recovery action
- describe the issue in plain language, not only in color or iconography
A poor pattern is one where the user sees a red border but no message, or where a dialog closes without returning focus to the original action. Those failures create friction and can become blockers for users with cognitive or motor differences.
Progressive disclosure and clarity
Many interfaces become less accessible as they gather more content, options, and controls. Beverly should prefer progressive disclosure and clear hierarchy over trying to make everything visually equal.
That means:
- important actions should be obvious and easy to reach
- secondary actions should not compete visually with primary actions
- dense layouts should still preserve a clear reading order
- tooltips, popovers, and menus should not hide essential context from keyboard users
- multi-step workflows should keep the user oriented at each step
In other words, more power should not mean more noise. Good accessibility design makes the important path easier, not harder.
Policy-aware accessibility
Accessibility is also shaped by environment and user preference. The system should not treat reduced motion, high contrast, or low-vision preferences as optional extras.
At the foundation level, Beverly should support policies such as:
- reduced motion for animation-heavy states
- high-contrast mode for interfaces where subtle visual separation is insufficient
- effective focus visibility under custom themes and branded surfaces
- stronger text legibility when transparency or blur reduces clarity
These policies should be part of the design system, not something each individual component manually implements. Once the system understands user preference, accessibility decisions become more consistent across the UI.
Example: policy-driven contrast and motion
#![allow(unused)]
fn main() {
use bevy::prelude::*;
use beverly::prelude::*;
fn apply_accessibility_policy(mut commands: Commands) {
let reduce_motion = true;
let high_contrast = false;
commands.spawn((
NodeBundle::default(),
BeverlySurface::new()
.with_policy(AccessibilityPolicy::from_context(
reduce_motion,
high_contrast,
)),
));
}
}
This is the practical version of accessibility as a system concern: the application responds to context without requiring developers to hand-tune every widget for each preference.
Practical implementation guidance
Beverly should encourage developers to build accessibility into the component lifecycle:
- define semantics before styling
- keep state and labels explicit
- maintain a stable focus model
- support keyboard-first operation
- test contrast and motion behavior at the theme level
- verify that critical flows still work when visual effects are reduced
- ensure recovery and error states are understandable without relying on color alone
- validate that navigation remains coherent in dense, dynamic interfaces
Example: component pattern checklist
#![allow(unused)]
fn main() {
struct AccessibleButton {
label: &'static str,
action: fn(),
disabled: bool,
focusable: bool,
}
impl AccessibleButton {
fn render(&self) {
assert!(!self.label.is_empty());
assert!(self.focusable);
// keyboard handling, visible focus styles, and state labels are required
}
}
}
This simple pattern shows the underlying rule: an interactive component should define not only how it looks, but how it behaves, how it is named, and how it communicates state.
This is the strongest way to reduce repeated fixes. When multiple components follow the same semantic and state rules, the app feels coherent and resilient across every screen.
Accessibility checklist
Before a component or screen is considered complete, it should answer these questions:
- Can the user reach every real action with the keyboard?
- Is the focused element clearly visible?
- Does focus move predictably when dialogs, panels, or lists update?
- Are labels and state meanings clear without relying on color?
- Does reduced motion preserve usability and clarity?
- Are status changes visible and understandable to assistive technology users?
- Is the layout readable in both standard and high-contrast modes?
When these conditions hold, the interface is not merely decorative. It is usable, resilient, and aligned with the broader Beverly design philosophy.
Component Accessibility Checklist
This matrix is the accessibility contract for Beverly’s interactive surfaces. It is intentionally explicit about partial coverage and manual validation.
Status meanings:
- Yes: implemented in Beverly’s semantic/focus/keyboard systems.
- Partial: the core semantics exist, but a documented state or platform behavior still needs component-specific verification.
- Gap: not currently wired by the component.
- Manual: requires VoiceOver, NVDA, JAWS, Orca, or real-window testing.
- N/A: not part of this component’s contract; for example, a notification
is not a bound input field. Application-owned relationships still use
SemanticRelationshipsrather than guessed associations.
| Component | Role | Name | Value/state | Keyboard | Focus | Disabled | Error/validation | Relationships | Dynamic announcements |
|---|---|---|---|---|---|---|---|---|---|
BeverlyButton | Yes: Button | Yes: text/custom text child | Yes: disabled | Partial: activation routing exists; shortcut coverage needs manual verification | Yes: tab index/focus ring | Yes | N/A: not a field | App-owned relationships | Manual: live-region adapter available |
Link | Yes: Link | Yes: Link::text | Yes: destination URL projected | Partial: shared activation | Partial: platform verification | Yes: dynamic state | N/A: not a field | App-owned relationships | Manual: live-region adapter available |
Checkbox | Yes: Checkbox | Yes: config label | Yes: checked/indeterminate/disabled | Yes | Partial: platform verification | Yes | Yes: bound error/invalid projection | Yes: bound error/description targets | Manual |
Toggle | Yes: Switch | Yes: config label | Yes: checked/disabled | Yes | Partial: platform verification | Yes | Yes: bound error/invalid projection | Yes: bound error/description targets | Manual |
RadioGroup | Yes: RadioGroup/Radio | Yes: group label | Yes: selected value | Partial: roving behavior needs platform verification | Partial: platform verification | Yes: group disabled guards | Yes: bound error projection | Yes: group ownership and errors | Manual |
Select<T> | Yes: ComboBox/ListBox | Yes: persistent .label(...) | Yes: value/expanded | Partial: platform verification | Partial: platform verification | Yes | Yes: binding errors on trigger | Yes: controls/controlled-by/active descendant | Manual |
Slider | Yes: Slider | Yes: accessible label | Yes: numeric range/value | Yes: arrows/home/end | Partial: platform verification | Yes | Yes: bound error/invalid projection | Yes: bound error/description targets | Manual |
TextInput | Yes: TextInput/SearchBox | Yes: explicit label/floating label | Yes: value/required/invalid/readonly | Yes: text editing keys | Partial: platform verification | Yes | Yes: binding errors project automatically | Yes: error/description targets | Manual: assertive validation region |
Textarea | Yes: TextInput multiline | Yes: explicit label | Yes: value/required/readonly | Yes: text editing keys | Partial: platform verification | Yes: config and edit guards | Yes: binding errors project automatically | Yes: error/description targets | Manual: assertive validation region |
Dropdown | Yes: ComboBox/ListBox/options | Yes: persistent trigger name | Yes: open/selected value | Partial: arrows/Enter/Escape need platform verification | Partial: platform verification | Yes: config and input guards | Yes: binding errors on trigger | Yes: controls/controlled-by/active descendant | Manual |
Tabs | Yes: TabList/Tab/TabPanel | Yes: tab labels | Yes: selected/active | Partial: roving navigation needs platform verification | Partial: platform verification | Yes: disabled config/guards | N/A: not a field | Yes: tab controls panel; panel labelled/controlled by tab | Manual |
Modal | Yes: Dialog | Yes: dialog title | Yes: modal/open state | Yes: Escape/focus scope | Yes: focus restoration | N/A: disable child controls or close dialog | N/A: errors belong to fields inside dialog | App-owned descriptions; child field errors supported | Manual |
FileInput | Yes: Button trigger | Yes: persistent FileInput::label | Yes: selected filenames | Partial: native picker requires platform verification | Partial: platform verification | Yes: picker/drop guards | Yes: bound error/invalid projection | Yes: bound error/description targets | Manual |
Search / SearchBox | Yes: named editable input; Search suggestions listbox | Yes: editor label | Yes: query/expanded/active suggestion | Partial: completion keys need platform verification | Yes: editor focus bridged; platform test pending | Yes: Search editor disabled state | Yes: errors reach editor | Yes: Search listbox controls/active descendant | Manual |
Pagination | Yes: List root/Button items | Yes: page/action labels | Yes: current page/disabled buttons | Partial: shared activation | Yes: tab indices | Yes | N/A: not a field | App-owned relationships | Manual |
Table | Yes: Table/Row/Cell/ColumnHeader | Yes: contextual column/cell labels; caller supplies table id | Yes: single-row selected state | Yes: arrows/Home/End/Enter/Space | Yes: cell/header tab stops and focus requests; platform test pending | Yes: explicit disabled semantic ancestors gate interactions | N/A: display data, not bound fields | Yes: semantic table/row/cell hierarchy | Manual |
ButtonGroup | Partial: Button items; caller supplies group name | Yes: item labels | Yes: selected/disabled | Partial: shared activation | Yes: tab indices | Yes | N/A: not a field | App-owned relationships | Manual |
PlayButton | Yes: Button | Yes: configured label | Yes: pressed/playing | Partial: shared activation | Partial: platform verification | Yes | N/A: not a field | App-owned relationships | Manual |
NavButton / Sidebar navigation | Yes: links/Menu controls | Yes: page labels/Menu | App-owned: active page state | Partial: shared activation | Yes: tab indices | Yes: explicit disabled semantics | N/A: not a field | App-owned routing relationships | Manual |
Alert / Toast | Yes: Alert/Status | Yes: message/title and dismiss name | Yes: dismissible/timer | Yes: dismiss controls have tab stops and shared activation | Partial: platform verification | N/A: notification; dismiss control can use disabled semantics | N/A: not a field | App-owned relationships | Yes: live-region projection; Manual: actual reader delivery |
Implemented Gap Closures
- Bound controls automatically project invalid state and an error-message child. Existing descriptions and error relationships are preserved and restored on validation recovery. Composite controls route errors to the editable trigger.
.error(|errors| ...)renders visible error content below supported form controls, including typed Selects, Forms, and submit buttons. Content refreshes only when nonempty errors change, is removed on recovery, and requests focus restoration when a focused error action disappears. Manual/global errors useBindingErrorState; bound-field errors remain setter-owned.AccessibilityAnnouncementmessages create polite/assertive AccessKit live regions, including repeated messages, and expire after one second.- Disabled semantics gate accessibility/automation activation and pointer interaction; generated interaction markers are removed on re-enabling without removing application-owned markers. Explicit disabled panels affect descendants.
- Textarea supports
.disabled(...),.read_only(...), and.required(...); protected editors reject text insertion, deletion, cut, and paste. - Dropdown and Select expose named listboxes, expanded state, selected values, hidden closed options, and trigger/listbox relationships.
- Table has directional cell/header navigation and persistent single-row selection. Search installs focus/completion routing and suggestion semantics.
- Alerts and Toasts expose named, tabbable dismiss buttons. Error/warning Alerts project assertive live regions; informational notices project polite regions.
Evidence includes validation_projects_errors_and_restores_existing_relationships,
validation_on_composite_container_reaches_its_focusable_trigger,
announcements_project_each_message_including_repeated_text,
disabled_semantics_block_automation_and_remove_only_owned_markers,
textarea_constraints_project_to_accesskit_and_prevent_edits,
dropdown_semantics_link_trigger_menu_and_active_option,
table_keyboard_navigates_and_selects_exactly_one_row,
search_editor_focus_and_suggestions_expose_a_listbox_and_dismiss, and
disabled_radio_group_rejects_activation_and_removes_tab_stops.
This is implementation coverage, not a screen-reader conformance claim.
Required component review
When adding or changing an interactive component, update its row and answer each question below with a source reference or a test name:
Semantics
- What semantic role is projected?
- Where does the accessible name come from?
- What value and state fields are projected?
- Which keyboard actions are supported?
- How does focus enter, move through, and leave the component?
- How is disabled behavior enforced for pointer, keyboard, and automation input?
- How are invalid state and validation messages represented?
- Which label, description, error, controls, owns, or active-descendant relationships are emitted?
- How are important dynamic changes announced?
Verification
- Add a headless semantic projection test where possible.
- Add a keyboard/focus system test for composite controls.
- Add a real-window smoke check for focus and pointer routing.
- Test at least one screen reader manually before calling platform behavior complete.
The repository’s headless tests verify semantic projection and deterministic file-drop target ordering. They do not verify native focus behavior, pointer routing in a real window, or what a platform screen reader announces. Those rows remain Partial or Manual until the real-window and assistive technology checks above are completed on supported operating systems.
Layout Overview
Layout primitives define how content flows and aligns across screens. In Beverly, layout is not just a visual convenience; it is the structure that keeps interfaces readable, resilient, and easy to compose across different app shells and data densities.
Core goals
- maintain a clear reading rhythm across panels and cards
- align content consistently without forcing all screens into the same rigid pattern
- support responsive composition for dashboards, forms, lists, and editor-style workspaces
- preserve hierarchy when content grows or shrink-wraps around dense controls
Layout model
The layout system is designed around a few reusable patterns:
- containers for page and panel boundaries
- stacks for sequencing content vertically or horizontally
- grids for structured alignment and column-based layouts
- splits for multi-panel workspaces
- scroll regions for dense or overflow-heavy surfaces
- viewport-aware structures for full-screen or app-shell composition
Example: dashboard shell
#![allow(unused)]
fn main() {
use bevy::prelude::*;
use beverly::prelude::*;
fn build_dashboard_shell(mut commands: Commands) {
commands.spawn((
NodeBundle::default(),
BeverlyContainer::new(),
)).with_children(|container| {
container.spawn(BeverlyStack::vertical()).with_children(|stack| {
stack.spawn(BeverlyTitle::new("Overview"));
stack.spawn(BeverlyGrid::new().columns(3)).with_children(|grid| {
grid.spawn(BeverlyCard::new("Revenue").with_body("$84.2k"));
grid.spawn(BeverlyCard::new("Tasks").with_body("18 active"));
grid.spawn(BeverlyCard::new("Alerts").with_body("3 pending"));
});
});
});
}
}
Design guidance
A strong layout system keeps visual rhythm predictable without making every screen feel templated. The right layout should make content easier to scan, not simply denser or more decorative.
Good layout decisions create a stable relationship between spacing, alignment, and priority. That makes it easier for users to understand what is important and how different sections relate to one another.
Container
Containers constrain content width and align content within a page. They are the most fundamental layout primitive for building readable screens without letting content stretch unpredictably across the viewport.
When to use
Use a container when you need to:
- lock content to a reasonable width
- align panels and sections consistently
- keep dense screens readable without full-width chaos
- create a stable shell for cards, forms, or dashboards
Example
#![allow(unused)]
fn main() {
use bevy::prelude::*;
use beverly::prelude::*;
fn build_container(mut commands: Commands) {
commands.spawn(Node::default()).with_children(|container| {
column()
.header(text("Workspace"))
.body(text("A central dashboard with deterministic spacing."))
.footer(text("Last synchronized just now"))
.spawn(container);
});
}
}
Header, Body, and Footer
Fluent containers created with column() or row() support .header(...),
.body(...), and .footer(...). These render top, middle, and bottom in a
vertical layout, regardless of call order. Each accepts a single element or
an iterable; repeated calls append to that section.
When sections are supplied, even a row() becomes a vertical section host.
Use row().children([...]) inside a section for horizontal content. Ordinary
container children join the body before explicit body content. The body grows
into available height, keeping the footer below it; unsized containers remain
content-sized. Without sections, existing row/column layout is unchanged.
The section entities carry ContentSection::Header, ContentSection::Body,
and ContentSection::Footer markers for application-specific styling.
Design guidance
Keep container width predictable and let internal spacing do the work of expression. The goal is clarity and rhythm, not heavy decoration. A well-sized container helps the user focus on the content rather than the screen boundaries.
Stack
Stacks provide vertical or horizontal grouping with spacing between elements. They are the simplest and most flexible way to compose sequences of controls, text, and content blocks in a predictable way.
When to use
Use a stack when the interface should feel linear and easy to follow: forms, action groups, theorem lists, cards, or command rows all work well in stacks.
Example
#![allow(unused)]
fn main() {
use bevy::prelude::*;
use beverly::prelude::*;
fn build_stack(mut commands: Commands) {
commands.spawn((
NodeBundle::default(),
BeverlyStack::vertical(),
)).with_children(|stack| {
stack.spawn(BeverlyTitle::new("Project setup"));
stack.spawn(BeverlyInput::new("Workspace name"));
stack.spawn(BeverlyButton::primary("Create workspace"));
});
}
}
Guidance
Use stacks to create natural reading flow. Vertical stacks are best for forms and narrative sections; horizontal stacks are best for short control groups such as actions or chips that belong together visually.
Grid
Grid layouts support structured alignment across rows and columns. They are ideal for dashboards, forms with repeated fields, data cards, and dense surfaces where alignment matters more than free-form stacking.
When to use
Use a grid when the interface contains multiple comparable items that should align cleanly. Examples include metric cards, list summaries, resource maps, and multi-column settings layouts.
Example
#![allow(unused)]
fn main() {
use bevy::prelude::*;
use beverly::prelude::*;
fn build_grid(mut commands: Commands) {
commands.spawn((
NodeBundle::default(),
BeverlyGrid::new().columns(3),
)).with_children(|grid| {
grid.spawn(BeverlyCard::new("Revenue").with_body("$84.2k"));
grid.spawn(BeverlyCard::new("Tasks").with_body("18 active"));
grid.spawn(BeverlyCard::new("Alerts").with_body("3 pending"));
});
}
}
Guidance
Grid layouts work best when the content is visually comparable. Keep column counts stable and avoid overcomplicating the rhythm with unusual spacing or inconsistent card sizes.
Split
Split layouts divide a surface into primary and secondary panels. They are useful in editor-style workspaces, settings pages, and dashboard shells where one area requires focus while another remains contextually available.
When to use
Use a split layout when:
- the user needs to compare two content areas at once
- a list or navigation panel remains visible while a details panel updates
- a primary workspace needs a persistent secondary context
Example
#![allow(unused)]
fn main() {
use bevy::prelude::*;
use beverly::prelude::*;
fn build_split_panel(mut commands: Commands) {
commands.spawn((
NodeBundle::default(),
BeverlySplit::horizontal(),
)).with_children(|split| {
split.spawn(NodeBundle::default());
split.spawn(NodeBundle::default());
});
}
}
Guidance
Keep the split proportion stable and ensure each pane still has enough breathing room. A split layout should not become a cramped crowding mechanism for unrelated content.
Scroll
Scroll regions provide predictable overflow handling for dense interfaces.
Viewport
Viewport primitives support full-screen composition and responsive surfaces. They are the right abstraction when the app needs a full-bleed shell, immersive canvas, or global layout container that reacts to the available screen space.
When to use
Use a viewport for:
- full-screen editor surfaces
- media or playback experiences
- app-shell composition with persistent chrome
- screens that should adapt directly to browser or window size
Example
#![allow(unused)]
fn main() {
use bevy::prelude::*;
use beverly::prelude::*;
fn build_viewport(mut commands: Commands) {
commands.spawn((
NodeBundle::default(),
BeverlyViewport::new(),
)).with_children(|viewport| {
viewport.spawn(BeverlyAppShell::new());
});
}
}
Guidance
Viewport-based composition is powerful, but it should preserve a clear hierarchy and safe margins. The user should feel grounded in the interface even when the surface is immersive or full-bleed.
Rendering Overview
Beverly rendering is intentionally material-first: surfaces, paint layers, and shader-backed effects are treated as part of the UI system rather than as app-specific hacks. The design borrows from the practical reference stack used in theblocks_studio, where a shared Surface model is extended with gradients, shadows, blur, and glass-like material treatments.
Core ideas
- UI surfaces carry semantic paint and border information
- material parameters are data-driven, not hardcoded per widget
- shader effects are used only when they improve readability or depth
- reduced-motion and reduced-transparency safeguards remain part of the design contract
Typical rendering pipeline
#![allow(unused)]
fn main() {
use bevy::prelude::*;
use beverly::prelude::*;
fn build_glass_panel(mut commands: Commands) {
commands.spawn((
NodeBundle::default(),
BeverlySurface::new()
.with_material(MaterialPreset::LiquidGlass)
.with_corner_radius(24.0),
));
}
}
Effects stack
The rendering stack usually progresses from:
- base surface fill
- gradient and border painting
- shadow and highlight layers
- blur or translucency
- focus and interaction overlays
This keeps the UI legible while allowing richer branded surfaces.
Background Color
Live Demo
Open the demo in a separate tab.
Background fills are the starting point for Beverly’s shader-backed surfaces. Use Surface with Paint::solid for a flat color, adjust its alpha for transparency, and overlap surfaces to compose layers.
BeverlyPlugin includes UiRenderingPlugin, which renders these surfaces. No custom shader or material registration is needed for the examples below.
Solid Fill
Attach a Surface to a Bevy UI Node. The node supplies the size and layout; the surface supplies the shape and paint.
#![allow(unused)]
fn main() {
use beverly::rendering::{Paint, Surface};
use bevy::prelude::*;
fn add_background(parent: &mut ChildSpawnerCommands) {
parent.spawn((
Node {
width: Val::Px(240.0),
height: Val::Px(192.0),
..default()
},
Surface::rounded_rect_fill(
0.0,
Paint::solid(Color::srgb(0.12, 0.68, 0.48)),
),
));
}
}
The first argument is the corner radius in pixels. 0.0 gives square corners. Color::srgb creates an opaque color with RGB channels from 0.0 to 1.0.
Use the surface as the visible fill instead of adding a second colored BackgroundColor underneath it. For an ordinary Bevy background without Beverly’s material effects, BackgroundColor remains available.
Transparency
Set the paint color’s alpha to control how much of the content behind it shows through:
#![allow(unused)]
fn main() {
use beverly::rendering::{Paint, Surface};
use bevy::prelude::*;
let mint = Color::srgb(0.12, 0.68, 0.48);
let translucent = Surface::rounded_rect_fill(
0.0,
Paint::solid(mint.with_alpha(0.65)),
);
}
- Alpha
1.0: fully opaque. - Alpha
0.0: fully transparent. - Values between them blend the fill with what is behind it.
This changes the fill, not the opacity of the entire UI subtree. Child text, icons, borders, and other effects retain their own colors and opacity. A transparent node also retains its layout and interaction behavior.
Transparency does not blur or distort the background. Those effects belong to Backdrop Blur and Liquid Glass.
Layered Surfaces
Place overlapping surfaces inside a shared layout parent. Later siblings normally draw above earlier ones; use ZIndex when explicit ordering is needed.
#![allow(unused)]
fn main() {
use beverly::rendering::{Paint, Surface};
use bevy::prelude::*;
fn add_layers(parent: &mut ChildSpawnerCommands) {
parent.spawn(Node {
width: Val::Px(240.0),
height: Val::Px(192.0),
position_type: PositionType::Relative,
..default()
}).with_children(|layers| {
layers.spawn((
Node {
width: Val::Percent(100.0),
height: Val::Percent(100.0),
..default()
},
Surface::rounded_rect_fill(
0.0,
Paint::solid(Color::srgb(0.94, 0.30, 0.34)),
),
));
layers.spawn((
Node {
position_type: PositionType::Absolute,
left: Val::Px(48.0),
top: Val::Px(36.0),
width: Val::Px(144.0),
height: Val::Px(120.0),
..default()
},
Surface::rounded_rect_fill(
0.0,
Paint::solid(Color::srgb(0.12, 0.68, 0.48).with_alpha(0.65)),
),
));
});
}
}
A checkerboard behind a translucent surface makes its alpha easier to inspect. The demo uses this pattern for its transparent and layered previews.
Updating Colors
Change Surface::fill in an application system to update a material at runtime:
#![allow(unused)]
fn main() {
use beverly::rendering::{Paint, Surface};
use beverly::theme::ThemeResource;
use bevy::prelude::*;
#[derive(Component)]
struct ThemeBackground;
fn update_backgrounds(
theme: Res<ThemeResource>,
mut surfaces: Query<&mut Surface, With<ThemeBackground>>,
) {
let fill = Paint::solid(theme.current.colors.background);
for mut surface in &mut surfaces {
if surface.fill != fill {
surface.fill = fill.clone();
}
}
}
}
Register this system in Update and attach ThemeBackground to the surfaces it should control. An explicitly supplied color is not automatically replaced when light/dark mode changes; read ThemeResource when a fill should follow the theme.
Check text contrast against the final blended background, not just the unblended fill color.
Demo
Run the background-color demo from the repository root:
cargo run --example background_color
cargo run --example background_color -- dark
The demo includes five color swatches, a 0–100% opacity slider, and three previews: solid fill, transparency over a checkerboard, and overlapping colored surfaces. The solid preview remains opaque while the other two follow the opacity control. The top-right button switches the page between light and dark mode.
Borders
Live Demo
Open the demo in a separate tab.
Beverly’s Surface renderer paints borders around a surface’s shape. A border combines widths in pixels with a Paint, which can be a solid color or a gradient.
BeverlyPlugin includes the rendering plugin required for these examples. For shared design values rather than rendering APIs, see Border Tokens.
Uniform Border
Use uniform_border to give every side the same width and paint:
#![allow(unused)]
fn main() {
use beverly::rendering::{Paint, Surface};
use bevy::prelude::*;
fn add_bordered_surface(parent: &mut ChildSpawnerCommands) {
parent.spawn((
Node {
width: Val::Px(240.0),
height: Val::Px(120.0),
padding: UiRect::all(Val::Px(20.0)),
..default()
},
Surface::rounded_rect_fill(8.0, Paint::solid(Color::WHITE))
.uniform_border(
4.0,
Paint::solid(Color::srgb(0.12, 0.68, 0.48)),
),
));
}
}
The surface’s shape determines its corners; border thickness does not set the corner radius. A width of 0.0 makes the border invisible.
The equivalent explicit configuration is .border(Border::new(width, paint)).
Individual Sides
Use Border::per_side with BorderWidths::sides. The argument order is top, right, bottom, left.
#![allow(unused)]
fn main() {
use beverly::rendering::{Border, BorderWidths, Paint, Surface};
use bevy::prelude::*;
let underline = Surface::rounded_rect_fill(0.0, Paint::solid(Color::WHITE))
.border(Border::per_side(
BorderWidths::sides(0.0, 0.0, 4.0, 0.0),
Paint::solid(Color::srgb(0.5, 0.5, 0.5)),
));
let asymmetric = Surface::rounded_rect_fill(8.0, Paint::solid(Color::WHITE))
.border(Border::per_side(
BorderWidths::sides(2.0, 4.0, 6.0, 8.0),
Paint::solid(Color::srgb(0.12, 0.55, 0.90)),
));
}
Other width constructors include:
| Constructor | Result |
|---|---|
BorderWidths::all(width) | Same width on every side |
BorderWidths::symmetric(vertical, horizontal) | Top/bottom use vertical; left/right use horizontal |
BorderWidths::horizontal_vertical(horizontal, vertical) | Same pairing, with reversed argument order |
BorderWidths::ZERO | No border on any side |
The uniform and symmetric constructors clamp negative widths to zero. sides stores its arguments directly; use nonnegative widths or call .clamped_non_negative().
Gradient Border
Border paint is independent of fill paint. A surface can have a solid background and a gradient outline:
#![allow(unused)]
fn main() {
use beverly::rendering::{GradientStop, LinearGradient, Paint, Surface};
use bevy::prelude::*;
let surface = Surface::rounded_rect_fill(8.0, Paint::solid(Color::WHITE))
.uniform_border(
4.0,
Paint::linear(LinearGradient::horizontal(vec![
GradientStop::new(0.0, Color::srgb(0.12, 0.68, 0.48)),
GradientStop::new(1.0, Color::srgb(0.94, 0.30, 0.34)),
])),
);
}
This paints a continuous gradient around the outline. It does not assign a separate color to each side. See Gradients for additional paint types.
For a translucent solid border, use Paint::solid(color.with_alpha(opacity)). Border opacity does not change the surface fill or its child content.
Layout and Theme
Surface::border describes the painted border. Bevy’s Node::border describes layout border widths. Configure layout spacing explicitly when content needs room beside a thick outline; the demo uses padding to keep its labels clear of the painted edges.
Do not add a second visible BorderColor unless you intend to draw a separate Bevy border as well.
Explicit border colors remain unchanged when the theme switches. To follow the theme, read ThemeResource and update the border paint:
#![allow(unused)]
fn main() {
use beverly::rendering::{Paint, Surface};
use beverly::theme::ThemeResource;
use bevy::prelude::*;
#[derive(Component)]
struct ThemedOutline;
fn update_outlines(
theme: Res<ThemeResource>,
mut surfaces: Query<&mut Surface, With<ThemedOutline>>,
) {
let paint = Paint::solid(theme.current.colors.border);
for mut surface in &mut surfaces {
if let Some(border) = surface.border.as_mut() {
if border.paint != paint {
border.paint = paint.clone();
}
}
}
}
}
Register this system in Update and attach ThemedOutline to the relevant surfaces. Use surface.clear_border() to remove a border entirely. Border setters replace the existing border rather than adding another outline.
Demo
Run the interactive border demo from the repository root:
cargo run --example borders
cargo run --example borders -- dark
The demo includes four color swatches and a 0–16 pixel thickness slider. Its previews show uniform, bottom-only, asymmetric, and gradient borders. The asymmetric widths are half, one, one-and-a-half, and twice the slider value, in top/right/bottom/left order.
The gradient runs from the selected color to coral. Preview fills follow the active theme, and the top-right button switches between light and dark mode.
Rounded Corners
Live Demo
Open the demo in a separate tab.
Beverly renders rounded corners through the Shape attached to a Surface. Corner radii are measured in logical UI pixels; the Bevy Node supplies the surface’s dimensions.
BeverlyPlugin installs the renderer needed for these examples.
Uniform Radius
Pass a radius to Surface::rounded_rect_fill to round all four corners equally:
#![allow(unused)]
fn main() {
use beverly::rendering::{Paint, Surface};
use bevy::prelude::*;
fn add_rounded_surface(parent: &mut ChildSpawnerCommands) {
parent.spawn((
Node {
width: Val::Px(240.0),
height: Val::Px(120.0),
..default()
},
Surface::rounded_rect_fill(
16.0,
Paint::solid(Color::srgb(0.10, 0.48, 0.34)),
),
));
}
}
A radius of 0.0 produces square corners. Shape::rounded_rect(radius) provides the same shape for Surface::new or .with_shape(...).
Independent Corners
Shape::rounded_rect_corners accepts four radii in clockwise order: top left, top right, bottom right, bottom left.
#![allow(unused)]
fn main() {
use beverly::rendering::{Paint, Shape, Surface};
use bevy::prelude::*;
let surface = Surface::new(
Shape::rounded_rect_corners(0.0, 24.0, 48.0, 12.0),
Paint::solid(Color::srgb(0.72, 0.20, 0.26)),
);
}
Use 0.0 for any corner that should remain square. The corner order differs from border widths, which use top/right/bottom/left sides.
For direct data access, CornerRadii exposes top_left, top_right, bottom_right, and bottom_left. The uniform constructor clamps negative values to zero; independent corner constructors store their arguments directly. Supply nonnegative radii or sanitize them with CornerRadii::clamped_non_negative().
Pills and Circles
A pill uses a radius equal to half its shorter dimension. A circle additionally requires equal width and height.
#![allow(unused)]
fn main() {
use beverly::rendering::{Paint, Surface};
use bevy::prelude::*;
fn add_pill_and_circle(parent: &mut ChildSpawnerCommands) {
let fill = Paint::solid(Color::srgb(0.12, 0.40, 0.72));
parent.spawn((
Node {
width: Val::Px(240.0),
height: Val::Px(80.0),
..default()
},
Surface::rounded_rect_fill(40.0, fill.clone()),
));
parent.spawn((
Node {
width: Val::Px(120.0),
height: Val::Px(120.0),
..default()
},
Surface::rounded_rect_fill(60.0, fill),
));
}
}
Changing a square into a wider rectangle while retaining the same large radius creates a pill rather than a circle. Keep dimensions stable when the shape must remain circular.
Borders and Content
A painted border follows the surface shape:
#![allow(unused)]
fn main() {
use beverly::rendering::{Paint, Surface};
use bevy::prelude::*;
let surface = Surface::rounded_rect_fill(16.0, Paint::solid(Color::WHITE))
.uniform_border(2.0, Paint::solid(Color::srgb(0.5, 0.5, 0.5)));
}
The shader’s radius is separate from Bevy’s Node::border_radius. Changing only the node’s radius does not replace Surface::shape. Rounded surface paint also does not automatically clip child text, images, or icons; configure content clipping separately when required.
Leave sufficient padding so content stays clear of curved corners, especially inside pills and circles.
Resizing and Animation
Update Surface::shape to animate the radius. When a circular element changes size, set the radius to half its current size on every animation frame. Any explicitly configured mask should use the same shape.
#![allow(unused)]
fn main() {
use beverly::rendering::{Shape, Surface};
use bevy::prelude::*;
fn resize_circle(node: &mut Node, surface: &mut Surface, size: f32) {
let size = size.max(0.0);
node.width = Val::Px(size);
node.height = Val::Px(size);
surface.shape = Shape::rounded_rect(size * 0.5);
if let Some(mask) = surface.mask.as_mut() {
mask.shape = surface.shape;
}
}
}
Recalculate positioning as well when a growing element must remain centered. Keeping the old radius or top offset while enlarging a handle can produce a rounded square or an off-center circle.
Demo
Run the rounded-corners demo from the repository root:
cargo run --example rounded_corners
cargo run --example rounded_corners -- dark
The demo includes a uniform-radius slider and four independent-corner sliders, each ranging from 0 to 60 pixels. Fixed-size previews show uniform corners, independent corners, a pill, and a circle. The pill and circle retain a 60-pixel radius and 120-pixel height while the other previews follow the sliders.
The top-right toggle switches the surrounding page between light and dark mode.
Clipping and Masking
Live Demo
Open the demo in a separate tab.
Clip and Mask trim or fade a Surface’s own paint with a rounded-rectangle shape. Both are applied by the surface shader, so they follow the surface’s rounded corners and antialiasing. BeverlyPlugin installs the renderer needed for the examples below.
They affect the surface, not its child nodes. Neither one clips child text, images, or icons. For that, use Bevy’s Node::overflow on the parent.
Clip
#![allow(unused)]
fn main() {
use beverly::rendering::{Clip, GradientStop, LinearGradient, Paint, Surface};
use bevy::prelude::*;
fn add_clipped_surface(parent: &mut ChildSpawnerCommands) {
parent.spawn((
Node {
width: Val::Px(110.0),
height: Val::Px(100.0),
..default()
},
Surface::rounded_rect_fill(
0.0,
Paint::linear(LinearGradient::angle_degrees(45.0, vec![
GradientStop::new(0.0, Color::srgb(0.98, 0.76, 0.18)),
GradientStop::new(1.0, Color::srgb(0.94, 0.26, 0.32)),
])),
)
.with_clip(Clip::rounded_rect(28.0)),
));
}
}
Clip::rounded_rect(radius) rounds the corners of everything the surface paints. Use Clip::new(shape) with a Shape for independent corner radii; see Rounded Corners.
The clip box is the surface’s own box. It cannot be larger or offset, so changing its radius only trims the corners; it does not reveal or crop a different region.
Mask
#![allow(unused)]
fn main() {
use beverly::rendering::{Mask, Paint, Shape, Surface};
use bevy::prelude::*;
let masked = Surface::rounded_rect_fill(0.0, Paint::solid(Color::srgb(0.12, 0.72, 0.50)))
.with_mask(Mask::new(Shape::rounded_rect(28.0)).with_opacity(0.6));
}
A mask has the same shape and radius controls as a clip. Its opacity multiplies the coverage, so with_opacity(0.6) fades the painted surface to 60 percent. Mask::rounded_rect(radius) is a shorthand.
What They Affect
Clip and mask are multiplied together, then applied to the surface’s:
| Layer | Trimmed by clip and mask |
|---|---|
| Fill, including gradients | Yes |
| Border | Yes |
| Inner shadow | Yes |
| Backdrop blur and liquid glass | Yes |
| Outer shadow | No |
| Outer glow | No |
| Child nodes (text, icons, images) | No |
| Focus ring | Clip only |
Outer shadows and glows are drawn around the surface’s own shape and are not trimmed, so a clipped surface keeps a shadow shaped by its original corners. If you need the shadow to follow a larger radius, change the surface’s Shape as well. The demo’s “Clip + shadow” preview shows this.
A Clip also gates the focus ring. A Mask does not, so a faded surface keeps a full-strength focus ring.
Opacity
Both types accept an opacity from 0.0 to 1.0:
#![allow(unused)]
fn main() {
use beverly::rendering::{Clip, Mask};
let clip = Clip::rounded_rect(16.0).with_opacity(0.5);
let mask = Mask::rounded_rect(16.0).with_opacity(0.5);
}
Out-of-range values are clamped, and non-finite values fall back to 1.0. A mask or clip at opacity 0.0 hides the surface’s own paint entirely, which leaves only an outer shadow or glow.
Use a mask when the point is fading, for example during an exit animation. Use a clip when the point is trimming the shape, particularly if the surface’s focus ring should follow it.
Radius Limits
Radii are limited to half of the surface’s shorter side, so an oversized radius produces a pill or circle rather than an invalid shape. Negative radii are treated as zero. A square surface with a radius of at least half its side becomes a circle.
Updating at Runtime
Assign a new value to the surface field, or use the clear methods:
#![allow(unused)]
fn main() {
use beverly::rendering::{Clip, Mask, Surface};
fn set_cut(surface: &mut Surface, radius: f32, fade: f32) {
surface.clip = Some(Clip::rounded_rect(radius));
surface.mask = Some(Mask::rounded_rect(radius).with_opacity(fade));
}
fn remove_cut(surface: Surface) -> Surface {
surface.clear_clip().clear_mask()
}
}
A surface has one clip and one mask, so assigning a new value replaces the old one.
Fading Content
A mask fades only the surface’s own paint. To fade a whole component, such as a toast, also fade its text and icon colors in the same system. Beverly’s toast fade-out does both: it sets a mask opacity on each card’s surface and lowers the alpha of its text and icon colors together.
Demo
Run the interactive demo from the repository root:
cargo run --example clip_mask
cargo run --example clip_mask -- dark
The demo draws the same gradient square four times over a checkerboard, so transparency is visible:
- No clip or mask: the reference.
- Clip: its radius is controlled by the clip-radius slider (0 to 80 px).
- Mask: its radius and opacity are controlled by the mask-radius slider (0 to 80 px) and the mask-opacity slider (0 to 100 percent).
- Clip + shadow: a clipped square with a drop shadow, showing that the shadow is not trimmed.
The top-right toggle switches the page between light and dark mode.
Shaders
In Beverly, most UI shader work is expressed through a Surface, not through a different shader for every widget. A Surface describes a shape and its visual data; Beverly’s rendering plugin turns that description into a shared GPU material. Cards, panels, and controls can therefore share one renderer while keeping their own meaning and behavior.
Describe the surface in Rust. Let the renderer translate it for the GPU.
The Beverly path
The standard application setup registers Beverly’s renderer through BeverlyPlugin:
use bevy::prelude::*;
use beverly::prelude::BeverlyPlugin;
use beverly::rendering::prelude::*;
fn main() {
App::new()
.add_plugins(DefaultPlugins)
.add_plugins(BeverlyPlugin)
.add_systems(Startup, setup)
.run();
}
fn setup(mut commands: Commands) {
let stops = vec![
GradientStop::new(0.0, Color::srgb(0.10, 0.38, 0.72)),
GradientStop::new(1.0, Color::srgb(0.20, 0.72, 0.62)),
];
commands.spawn((
Node {
width: px(320.0),
height: px(120.0),
..default()
},
Surface::new(
Shape::rounded_rect(18.0),
Paint::linear(LinearGradient::horizontal(stops)),
)
.uniform_border(1.0, Color::srgba(0.8, 0.9, 1.0, 0.65))
.outer_shadow(OuterShadow::small(Color::BLACK)),
));
}
BeverlyPlugin installs UiRenderingPlugin, registers the material, embeds Beverly’s WGSL asset, and creates a default 2D UI camera when the app has not supplied one. Add a Bevy UI Node to give the entity layout and size; add a Surface to give it shader-rendered appearance. The example uses only the public surface API: a rounded rectangle, a horizontal two-stop gradient, a border, and a small shadow.
For the paint, border, shadow, blur, and glass APIs in more depth, see Materials, Gradients, Effects, and Liquid Glass.
What a Surface describes
Surface is the renderer-facing description of a UI surface. Its core fields are:
shape— currently a rounded rectangle with either one radius or per-corner radiifill— solid color, shimmer, or linear, radial, or angular gradient paintborder— optional border paint and widthsdecorations— semantic decoration such as a focus ringeffects— optional outer shadow, outer glow, or inner shadownoise,clip,mask, andbackdrop— optional surface treatments
These are data, not independent widget render paths. For example, changing the fill does not change the entity’s layout or turn a card into a different component. It changes the paint data that the renderer sends to the shader.
From layout to fragment
The rendering flow keeps responsibilities separate:
- Bevy UI computes the entity’s layout, size, and transform.
- Beverly reads the entity’s
Surfaceand the computed UI geometry. - Beverly encodes shape, paint, border, effect, and backdrop values into the material uniform.
- The UI material binds that data and the embedded WGSL evaluates the surface for each fragment.
This is why a Surface should be attached to an entity that participates in Bevy UI layout. The renderer needs the computed size and transform to evaluate its local shape, place the effects, and sample a backdrop correctly.
The GPU material is managed by UiRenderingPlugin. Applications should update the Surface component rather than creating or editing Beverly’s internal UiShapeMaterial directly. The renderer handles material synchronization as the surface data or computed layout changes.
Paint and geometry
Paint is independent from shape. The same fill can be applied to different rounded-rectangle radii, and a shape can switch from a solid fill to a gradient without changing its geometry.
#![allow(unused)]
fn main() {
let fill = Paint::linear(LinearGradient::new(
Vec2::new(0.0, 0.0),
Vec2::new(1.0, 1.0),
vec![
GradientStop::new(0.0, Color::srgb(0.12, 0.34, 0.76)),
GradientStop::new(0.55, Color::srgb(0.18, 0.70, 0.68)),
GradientStop::new(1.0, Color::srgb(0.82, 0.88, 0.43)),
],
));
let surface = Surface::new(Shape::rounded_rect(16.0), fill);
}
Gradient coordinates are normalized local coordinates: (0, 0) is the top-left and (1, 1) is the bottom-right. Beverly currently encodes up to four gradient stops for the GPU. Stops are normalized and sorted before encoding. Authored colors are converted to linear RGBA for shader evaluation; choose colors using Bevy’s color APIs rather than pre-linearizing them yourself.
Rounded corners are evaluated from the surface shape in the shader rather than by changing the widget’s layout. That lets the border, fill, and effects use the same shape description. The current shape API supports rounded rectangles, including distinct radii for each corner.
Effects, focus, and backdrops
Effects and decorations are part of the same surface description, but they serve different purposes. Shadows and glows add depth or emphasis; a focus ring communicates an interaction state. Keep focus visible independently of decorative styling, including when a surface is translucent.
#![allow(unused)]
fn main() {
let elevated = Surface::new(
Shape::rounded_rect(12.0),
Color::srgb(0.12, 0.15, 0.20).into(),
)
.outer_shadow(OuterShadow::regular(Color::BLACK));
}
Backdrop blur and liquid-glass treatments also use the shared material, but they depend on the renderer’s backdrop capture path. They are not equivalent to making a transparent fill: the renderer must capture and sample the content behind the surface. Use them selectively, and retain a readable fill or contrast treatment so text and controls remain legible.
Editing or replacing the shader
Beverly’s built-in shader is src/rendering/shaders/ui_shape.wgsl. Its Rust-side material and uniform definitions live alongside it in src/rendering/material.rs; UiRenderingPlugin embeds the WGSL and registers UiShapeMaterial as a Bevy UI material.
The Rust and WGSL uniform layouts are one contract. A change to a field’s type, order, alignment, or meaning must be made consistently on both sides. The shader also relies on the UI material’s vertex inputs and the backdrop texture and sampler bindings. Treat those bindings as part of the interface, not as incidental implementation details.
Most application styling does not need a custom shader: use Surface and its public paint/effect types. Consider a separate Bevy UiMaterial when the effect requires a different data model, bindings, or rendering behavior that the Surface contract does not represent. Keep that material and shader in the application or extension that owns the effect instead of coupling ordinary widgets to Beverly’s internal material type.
Debugging
Beverly includes renderer debug views for inspecting the rounded-rectangle distance field, border coverage and paint, gradient coordinates, noise, and focus-ring coverage. Select one with UI_RENDER_DEBUG when launching the app:
UI_RENDER_DEBUG=gradient_uv cargo run
Accepted values include sdf, border, gradient_uv, border_paint, noise, noise_coords, noise_strength, noise_modulation, focus, and focus_distance. The default is the final rendered surface. For a built-in rendering sample, run an application with UI_RENDERING_DEMO=1; backdrop diagnostics are available through UI_BACKDROP_DEBUG (source, blur, or mask), and backdrop rendering can be disabled with UI_BACKDROP_DISABLED=1.
When a surface is missing or malformed, check these in order:
- Confirm the entity has both a sized Bevy UI
Nodeand aSurface. - Confirm the app added
BeverlyPlugin(or registeredUiRenderingPlugindirectly). - Reduce the effect to a solid fill and rounded rectangle, then add paint and effects back one at a time.
- Use the matching debug view to distinguish bad geometry or uniform data from a paint or effect issue.
- Check the application log for shader compilation, asset, or render-pipeline diagnostics.
Performance and accessibility
The shared renderer avoids introducing a separate widget-specific shader path for each visual variant, but effects still have costs. In particular, backdrop sampling and large blur, glow, or shadow regions deserve attention on screens with many surfaces. Prefer a small number of intentional effects over applying the most expensive treatment to every row, chip, or button.
Keep the visual state understandable without relying on shader effects alone. Text contrast, selected and disabled states, and keyboard focus must remain clear if blur, noise, or transparency is reduced or unavailable. Beverly’s runtime effect policy can suppress selected effects; application content should still communicate its meaning through structure, labels, and state.
Use shaders to express a surface, not to hide the interface’s structure.
Materials
Materials connect a UI surface to its paint behavior. A material is the place where the app defines how a widget is filled, how it reacts to focus, and how it handles effects like blur, highlight, or noise. In a system like Beverly, materials are not just shaders with a few parameters; they are the interface between semantic UI state and the visual treatment applied to the surface.
What a material does
A material describes the visual rules for a surface. It answers questions such as:
- What color should this surface use?
- How strong is the border?
- Does this surface have a raised, flat, or glass-like treatment?
- How should hover, focus, and disabled states change the appearance?
- Does this surface support blur, transparency, or highlight layers?
This makes the material a shared contract that multiple component types can use while preserving their own semantics. A card, a modal, a button, and a panel can all rely on a consistent material model even if they are visually distinct.
Example
#![allow(unused)]
fn main() {
use bevy::prelude::*;
use beverly::prelude::*;
fn spawn_material_surface(mut commands: Commands) {
commands.spawn((
NodeBundle::default(),
BeverlySurface::new()
.with_fill(Color::srgb(0.08, 0.09, 0.15))
.with_border(Color::srgb(0.55, 0.68, 1.0), 1.5)
.with_material(MaterialPreset::Elevated),
));
}
}
This is a good example of material-driven UI. The component remains a surface, but the actual visual treatment is determined by a reusable preset rather than by a custom one-off style path.
Why materials matter
Materials are important because they keep visual logic centralized and coherent. Without a material model, the system ends up scattering styles across widgets, and each component becomes responsible for both semantics and paint decisions.
A material model solves several problems:
- consistent surfaces across the app
- easier theming and theme swapping
- better effect tuning in one place
- simpler support for reduced transparency and contrast policies
- fewer ad hoc visual variants to maintain
Material goals
- maintain a semantic surface layer instead of ad hoc color values
- keep the same material contract across components
- allow theme swaps without rewriting widgets
- make effect tuning easy to adjust in one place
- provide a predictable place for interaction-driven visual changes
Material hierarchy
A good material system usually has a few conceptual layers:
- base fill and border
- surface behavior such as elevated, flat, or glass-like
- optional effect parameters such as blur, highlight, and alpha
- interaction tuning such as hover and focus overlays
- policy adjustments for contrast, reduced motion, and transparency
This keeps the system readable. The material is not a catch-all for every visual decision, but a structured place to define how a surface behaves under normal and exceptional conditions.
Material and theme alignment
Material definitions should not be tied to a single widget. Instead, they should respond to theme tokens and broader design policy. That means the theme can change colors, surfaces, and contrast while the material interface remains stable.
This allows a product to evolve visually without rewriting component logic. The same button material can work in a light theme, dark theme, or a custom branded theme so long as the material contract remains consistent.
Interaction-aware materials
Materials should react to the state of the element. A focused control, a hovered button, or a disabled panel often need slightly different surface values even when they remain the same semantic object.
Examples:
- a hovered card may get a slightly brighter outline
- a focused control may gain a strong accent ring
- a disabled surface may reduce contrast and motion
- a selected surface may use a more distinct accent tint
This makes materials part of the interaction layer rather than a static background detail.
The core idea
A good material model is the bridge between UI semantics and visual treatment. It keeps the app consistent, combinable, and themeable without turning each widget into a bespoke rendering implementation.
In Beverly, that means materials are not a secondary concern. They are a core primitive in the rendering system and one of the most important tools for building coherent, polished surfaces.
Effects
Beverly effects should emphasize state and hierarchy instead of distracting from the content. Effects are most useful for focus, depth, and transitions between states. In a well-designed interface, effects should support meaning: they help the user understand that something is selected, active, elevated, or important, without becoming decoration for its own sake.
What counts as an effect
Effects are the visual treatments layered on top of a surface after the base structure is established. They include things such as:
- shadows
- glows and accents
- hover and press changes
- soft blur behind overlays
- translucent fills and overlays
- focus rings or emphasis outlines
These treatments should enhance clarity, not replace it. If a control becomes difficult to understand because the effect is too strong or too noisy, the design has crossed the line from helpful to distracting.
Example
#![allow(unused)]
fn main() {
fn build_effect_card(mut commands: Commands) {
commands.spawn((
NodeBundle::default(),
BeverlyCard::new("Sync status")
.with_shadow(ShadowPreset::Soft)
.with_glow(Color::srgb(0.35, 0.75, 1.0)),
));
}
}
This pattern is intentionally restrained. The glow and shadow raise the visual priority of the card without needing a giant custom implementation. The card remains a card; the effect communicates emphasis and separation.
Good effect patterns
- subtle hover lift
- focus rings on interactive controls
- soft elevation for modal and overlay surfaces
- restrained transparency for high-value UI containers
- subtle directional light or highlight response on premium surfaces
Effects are most useful when they support semantics. A focus ring should help the user locate the active control. A shadow should clarify depth. A glow should suggest emphasis without taking over the whole surface.
How effects fit the system
A well-structured UI framework treats effects as layer-level behavior rather than ad hoc widget styling. This keeps them aligned with the architecture. Layout determines shape and position. Materials define the base look. Effects add emphasis and depth. Policies decide whether those effects are appropriate in a given context.
This clean separation matters because it prevents each widget from absorbing its own visual logic. Instead, effect use remains consistent and easier to adjust when the design changes.
Reducing visual noise
As in theblocks_studio, effects should be controlled by a shared policy layer. This makes it easy to disable them for reduced motion, reduced transparency, or high-contrast applications.
A consistent policy layer is important because the same effect can be appropriate in one context and excessive in another. For example:
- a subtle shadow may be perfect for a card in normal mode
- the same shadow may be too heavy in a high-contrast mode
- a glow may be charming in a hero panel but distracting in a dense dashboard
A design system should not rely on every widget to decide these trade-offs independently.
Effects should support hierarchy
Effects are most useful when they guide attention without crowding content. The strongest use case is hierarchy: helping the user see what is active, lifted, selected, or important.
This is why Beverly’s effect model should be constrained, not maximal. Effects are a tool for emphasis, not a substitute for strong structure, spacing, and typography.
The right level of intensity
The best effect system is measured and compact. A small set of effect presets or parameters is easier to reason about than a large matrix of bespoke stylings. That leads to:
- more visual consistency
- less design drift between screens
- easier theming and product evolution
- better accessibility behavior under reduced transparency and motion settings
The key idea
Effects should help users understand the interface, not compete with it. In a structured design system, they are one layer of emphasis operating within a stronger visual hierarchy. That keeps the product polished without making it noisy or hard to use.
Blur
Live Demo
Open the demo in a separate tab.
Blur is one of the most effective ways to show depth and separation in a layered interface. Used sparingly, it helps create readable glass or ambient surfaces without reducing legibility. In a product UI, blur is most useful when it reinforces hierarchy and layering rather than when it becomes a decorative effect on its own.
What blur does in UI
A blurred surface suggests distance, depth, or separation from the content beneath it. It works well for:
- modal backdrops
- floating utility surfaces
- translucent overlays and chrome
- layered panels that need subtle separation
- premium product surfaces that want a glass-like feel
The key is that blur should support the interface’s readability, not undermine it. If text becomes hard to read, the surface has crossed from excellent layering into bad legibility.
Backdrop blur
A Backdrop blurs and adjusts the pixels already rendered behind a surface, rather than blurring the surface’s own fill. Attach it with Surface::with_backdrop; BeverlyPlugin installs the renderer and capture pass.
#![allow(unused)]
fn main() {
use beverly::rendering::{Backdrop, BackdropQuality, Paint, Surface};
use bevy::prelude::*;
fn add_frosted_panel(parent: &mut ChildSpawnerCommands) {
parent.spawn((
Node {
width: Val::Px(310.0),
height: Val::Px(180.0),
padding: UiRect::all(Val::Px(22.0)),
..default()
},
Surface::rounded_rect_fill(8.0, Paint::solid(Color::NONE))
.uniform_border(1.0, Paint::solid(Color::WHITE.with_alpha(0.65)))
.with_backdrop(
Backdrop::new()
.with_blur(18.0)
.with_tint(Color::srgb(0.96, 0.96, 0.96))
.with_tint_opacity(0.20)
.with_quality(BackdropQuality::High),
),
));
}
}
The blur follows the surface’s shape, including rounded corners. The example uses a transparent fill so the frosted backdrop is the visible material. A fill, border, or shadow on the same surface draws over it.
Parameters
| Setter | Effect |
|---|---|
with_blur(pixels) | Blur radius in pixels, capped at 128. 0 disables blurring |
with_tint(color) | Color mixed over the blurred backdrop |
with_tint_opacity(value) | Tint strength from 0.0 to 1.0; also scaled by the tint color’s alpha |
with_saturation(factor) | Color intensity; 1.0 is unchanged, 0.0 is greyscale |
with_brightness(factor) | Brightness; 1.0 is unchanged |
with_contrast(factor) | Contrast; 1.0 is unchanged |
with_quality(quality) | Blur sample count: Low, Medium (default), or High |
Saturation, brightness, and contrast are clamped to 0.0–2.0. A default Backdrop::new() has no visible effect until at least one parameter changes. Use .with_liquid_glass(...) to add refraction; see Liquid Glass.
Quality
Quality controls how many samples the blur takes over its radius, in addition to the center sample:
Low: 8 samplesMedium: 16 samplesHigh: 32 samples
Higher quality produces smoother frosting, especially with a large radius or high-contrast content behind the surface. It also costs more GPU time, so reserve High for a few prominent surfaces.
What gets blurred
Backdrop surfaces share one snapshot of the UI taken before the first surface that samples it is drawn. This has a few consequences:
- Only content drawn before the blurred surface appears in the blur.
- A backdrop surface does not blur another backdrop surface; stacked glass does not accumulate.
- SVG icons and later overlay cameras are not captured.
- Beverly supports a single full-window camera for capture.
Place the content that should be blurred behind the surface in the same UI tree, earlier in draw order.
Updating at runtime
Replace Surface::backdrop to change the effect:
#![allow(unused)]
fn main() {
use beverly::rendering::{Backdrop, Surface};
use bevy::prelude::*;
fn set_blur(surface: &mut Surface, blur: f32) {
surface.backdrop = Some(Backdrop::new().with_blur(blur).with_tint_opacity(0.2));
}
}
Use surface.clear_backdrop() to remove it. An explicit tint color is not replaced when the theme changes; build it from ThemeResource when it should follow light or dark mode.
Developer diagnostics
Two environment variables help while debugging:
UI_BACKDROP_DISABLED=1turns off backdrop capture and sampling.UI_BACKDROP_DEBUG=source,blur, ormaskshows the captured backdrop, the blurred result, or the surface mask.
Demo
Run the interactive backdrop-blur demo from the repository root:
cargo run --example backdrop_blur
cargo run --example backdrop_blur -- dark
The demo places two windows over a moving, striped background: a clear view with a light dark tint, and a frosted view. Sliders adjust the frosted window’s blur (0–64 px), tint (0–100%), and saturation (0–200%). The frosted view uses High quality, and the stripes hold still when reduced motion is enabled.
Compare the two windows to see how blur softens the edges behind the frosted one. The top-right toggle switches light and dark mode, which changes the frosted tint color.
Use cases
- modal overlays
- floating panes and sidebars
- translucent chrome and utility surfaces
- layered data views with a quiet background
- premium application surfaces that need a softened, atmospheric layer
Accessibility note
If blur reduces contrast too much, the surface should fall back to a more opaque treatment or a higher-contrast border. In accessibility-driven design, blur cannot be allowed to hide critical text or destroy state readability.
Blur and context
The best use of blur is usually conservative. A little blur can create a sense of separation and focus, but too much blur will make a layer feel vague and reduce confidence in the interface. In dense workspaces, the user needs to understand the structure at a glance.
This means blur should be used with clear boundaries:
- behind modal shells or drawers
- around focus regions or transient surfaces
- on layered chrome that should feel ambient rather than dominant
- not on every text-heavy or dense data surface by default
Performance and visual cost
Blur is often more expensive than a flat fill or a simple shadow because it requires more GPU work and more blending. That does not make it invalid; it just makes proper scope and restraint important.
A thoughtful bloom or blur policy keeps the effect active only where it adds value. This is especially important for dashboards, data-heavy screens, and mobile or lower-power devices.
The design principle
Blur is most successful when it communicates depth without hiding structure. It should make the interface feel layered and refined, while still allowing the user to read, focus, and act with confidence.
Used correctly, blur is a quiet luxury. Used without constraint, it becomes a readability problem.
Gradients
Live Demo
Open the demo in a separate tab.
Gradients add polish and visual hierarchy to surfaces without requiring custom widget logic. They are often used for hero panels, accent areas, and interactive emphasis. In a design system, gradients are best treated as a controlled surface tool rather than as a free-form visual flourish layered onto every card.
What gradients do well
A gradient can help the interface do several useful things:
- establish brand tone in a hero or shell surface
- create subtle emphasis without relying on a harsh solid fill
- signal focus or strong state without creating an overly loud border
- add a premium feel to overlays, panels, or onboarding surfaces
They are especially effective when used in limited, purposeful places. A gradient can help define a visual hierarchy, but it should not replace structure, spacing, or typography as the source of meaning.
Example
#![allow(unused)]
fn main() {
use beverly::rendering::{GradientStop, LinearGradient, Paint, Surface};
use bevy::prelude::*;
fn build_gradient_panel(parent: &mut ChildSpawnerCommands) {
parent.spawn((
Node {
width: Val::Px(240.0),
height: Val::Px(240.0),
..default()
},
Surface::rounded_rect_fill(
8.0,
Paint::linear(LinearGradient::angle_degrees(45.0, vec![
GradientStop::new(0.0, Color::srgb(0.08, 0.62, 0.42)),
GradientStop::new(0.5, Color::srgb(0.98, 0.82, 0.20)),
GradientStop::new(1.0, Color::srgb(0.94, 0.28, 0.34)),
])),
),
));
}
}
BeverlyPlugin installs the surface renderer. The node supplies dimensions; Surface supplies the rounded shape and gradient paint. No custom shader registration is needed.
Linear Gradients
Linear gradients blend colors along a line. Use LinearGradient::horizontal(stops), vertical(stops), angle_degrees(angle, stops), or angle_radians(angle, stops).
For explicit endpoints, use LinearGradient::new(start, end, stops). Coordinates are normalized to the surface: (0, 0) is top-left and (1, 1) is bottom-right.
The angle constructors use 0 degrees for left-to-right and 90 degrees for top-to-bottom. Positive angles follow the UI’s downward Y axis. On nonsquare surfaces, normalized coordinates can stretch the visual direction.
Radial Gradients
A radial gradient blends outward from a center. radius is a normalized Vec2, allowing different horizontal and vertical extents:
#![allow(unused)]
fn main() {
use beverly::rendering::{GradientStop, Paint, RadialGradient, Surface};
use bevy::prelude::*;
let radial = Surface::rounded_rect_fill(
8.0,
Paint::radial(RadialGradient::new(
Vec2::splat(0.5),
Vec2::new(0.65, 0.45),
vec![
GradientStop::new(0.0, Color::srgb(0.98, 0.82, 0.20)),
GradientStop::new(1.0, Color::srgb(0.08, 0.62, 0.42)),
],
)),
);
}
RadialGradient::circular(center, radius, stops) sets equal normalized radii on both axes. It looks circular on a square surface; unequal surface dimensions stretch it. Smaller radii concentrate the transition closer to the center.
Angular Gradients
Angular gradients blend around a center over one full turn:
#![allow(unused)]
fn main() {
use beverly::rendering::{AngularGradient, GradientStop, Paint, Surface};
use bevy::prelude::*;
let angular = Surface::rounded_rect_fill(
8.0,
Paint::angular(AngularGradient::angle_degrees(
Vec2::splat(0.5),
45.0,
vec![
GradientStop::new(0.0, Color::srgb(0.12, 0.55, 0.90)),
GradientStop::new(0.5, Color::srgb(0.94, 0.28, 0.34)),
GradientStop::new(1.0, Color::srgb(0.12, 0.55, 0.90)),
],
)),
);
}
AngularGradient::new(center, angle_radians, stops) accepts radians directly. Zero points right; positive angles rotate clockwise. Equal first and last colors give a continuous wrap. Different endpoint colors create a visible boundary at the start of the turn.
Color Stops
GradientStop::new(position, color) places a color along the gradient. Positions range from 0.0 to 1.0; moving a middle stop changes how much space the neighboring transitions occupy.
- The shader supports up to five stops (
MAX_GRADIENT_STOPS). Keep authored gradients within that limit. - Normalization sorts stops and clamps positions to the valid range. Equal positions retain the last supplied stop.
- Color alpha controls transparency at each stop. The resulting fill blends with the content behind the surface, not with child text or icons.
- Dithering is enabled by default to reduce visible banding. Use
.with_dithering(false)on a gradient to compare the result without it.
Gradient paint can also be used for a border, independently of the surface fill.
Runtime Updates
Replace Surface::fill with a new Paint::linear, Paint::radial, or Paint::angular in an application system to update the gradient. These gradient types are static until their parameters change; they do not automatically rotate or animate.
Explicit stop colors are not replaced when the theme changes. Build stops from ThemeResource when a gradient should follow theme tokens. For animated parameters, honor reduced-motion preferences in the system driving them.
Demo
Run the interactive gradient demo from the repository root:
cargo run --example gradients
cargo run --example gradients -- dark
The demo shows linear, radial, and angular previews with Fresh, Seaside, and Mono palette swatches. The angle slider controls linear direction and angular rotation; the middle-stop slider changes all three gradients; radial size changes only the radial preview. The previews use square surfaces so radial geometry is easy to compare.
The top-right toggle switches the surrounding page between light and dark mode while the chosen palette remains unchanged.
Animated borders
Surface::animated_border paints the border with a gradient that spins around the surface. It is added like any other surface treatment, and the animation runs on the GPU clock, so it needs no system of your own:
#![allow(unused)]
fn main() {
Surface::rounded_rect_fill(8.0, Color::WHITE)
.uniform_border(1.0, Color::NONE)
.animated_border(SpinningGradient::default());
}
The default is a neutral line with a blue and near-white highlight chasing around it. Pass your own stops to change the colors. The speed is fixed, and up to five stops are used:
#![allow(unused)]
fn main() {
Surface::rounded_rect_fill(8.0, Color::WHITE).animated_border(SpinningGradient::new([
GradientStop::new(0.0, Color::srgb(0.9, 0.2, 0.4)),
GradientStop::new(0.5, Color::srgb(1.0, 0.8, 0.2)),
GradientStop::new(1.0, Color::srgb(0.9, 0.2, 0.4)),
]));
}
Stops are positions around one full turn. Keep the first and last colors equal so the loop has no seam. An existing border width is kept; otherwise the border is 1px. The spin stops when the user prefers reduced motion.
Guidance
- use gradients to direct attention, not as a replacement for strong hierarchy
- keep contrast high enough for readable text
- prefer soft, controlled transitions over dramatic color shifts
- keep brand gradients limited to key surfaces rather than spreading them through the entire app
- ensure gradients remain readable under dark, light, and high-contrast modes
Gradients and content legibility
A gradient is only successful when the surrounding content remains easy to read. A bright, saturated gradient behind dense text can quickly reduce legibility and make the interface harder to use.
This is why Beverly should favor gradients that are:
- soft rather than aggressive
- harmonious with the app’s color system
- easy to pair with text and border contrast
- intentional and limited in scope
Theme-aware use
Gradients should feel like part of the theme, not as random decoration. The system should use tokens and colors from the product design language so gradients remain consistent across surfaces and screens.
A gradient used in one screen should feel like it belongs to the same visual system as the rest of the product, not like a special effect imported from elsewhere.
The core idea
Gradients are a surface-level tool for emphasis and atmosphere. They should add quality to the design without introducing visual noise or reducing readability. In a strong design system, they are used purposefully, consistently, and with accessibility in mind.
Noise and Texture
Live Demo
Open the demo in a separate tab.
Noise adds procedural grain to a Surface. It varies the surface’s brightness across its area, which breaks up flat fills and gradients and adds tactile character. The shader generates it, so no texture asset is needed. BeverlyPlugin installs the renderer used by the examples below.
Basic Usage
#![allow(unused)]
fn main() {
use beverly::rendering::{GradientStop, LinearGradient, Noise, Paint, Surface};
use bevy::prelude::*;
fn add_grainy_surface(parent: &mut ChildSpawnerCommands) {
parent.spawn((
Node {
width: Val::Px(300.0),
height: Val::Px(170.0),
..default()
},
Surface::rounded_rect_fill(
8.0,
Paint::linear(LinearGradient::angle_degrees(45.0, vec![
GradientStop::new(0.0, Color::srgb(0.98, 0.76, 0.18)),
GradientStop::new(1.0, Color::srgb(0.94, 0.26, 0.32)),
])),
)
.with_noise(Noise::grain(8.0, 0.12)),
));
}
}
Noise::grain(scale, strength) takes the grain size in pixels and a strength from 0.0 to 1.0. A default Noise has zero strength and is invisible, so always set a strength. Use surface.clear_noise() to remove it.
How It Works
The shader samples smooth value noise on a grid spaced scale pixels apart, producing a value between -1 and 1 for each pixel. It then multiplies the pixel’s brightness by 1 + noise * strength.
- All color channels are multiplied by the same factor, so noise darkens and lightens a color without shifting its hue.
- Alpha is not changed. Noise never makes a surface more or less transparent.
- Brightness is scaled, not offset, so a pure black fill stays black. A white fill can only get darker, because its channels are already at the maximum. Mid-tones show the most grain. The demo includes light, mid, and black swatches so you can see this.
- The pattern is defined in the surface’s local coordinates, so it moves with the surface rather than staying fixed on screen.
Parameters
| Setter | Default | Valid range | Effect |
|---|---|---|---|
Noise::grain(scale, strength) | 24 px, 0 | scale 0.001 to 4096, strength 0 to 1 | Grain size and amplitude |
with_seed(seed) | 0 | finite values | Picks a different but repeatable pattern |
with_animated(bool) | false | Scrolls the noise over time | |
with_speed(speed) | 0 | 0 to 16 | Scroll speed; needs animated and a speed above zero to move |
with_target(target) | Surface | see below | Which part of the surface is textured |
Out-of-range values are clamped and non-finite values fall back to their defaults, so extreme input cannot break the shader. NoiseKind currently has one value, Grain, and NoiseSpace has one value, Local; both are set for you.
Grain size
Small values (1 to 3 px) look like fine film grain. Larger values give soft, cloudy mottling. The grain size is in physical pixels and is not scaled by the display’s density, so the same value looks finer on a high-density display than on a standard one. Check the result on the displays you target.
Seed
Two surfaces with the same size and settings produce identical grain, because the pattern comes from local coordinates. Give each its own seed when they sit side by side and the repetition would be noticeable.
Targets
NoiseTarget chooses which layer is textured:
| Target | Textures |
|---|---|
Surface (default) | The fill, the border, and the blurred backdrop together |
Fill | The fill only |
Border | The border only |
#![allow(unused)]
fn main() {
use beverly::rendering::{Noise, NoiseTarget, Paint, Surface};
use bevy::prelude::*;
let surface = Surface::rounded_rect_fill(8.0, Paint::solid(Color::srgb(0.5, 0.5, 0.5)))
.uniform_border(6.0, Paint::solid(Color::srgb(0.18, 0.55, 0.94)))
.with_noise(Noise::grain(4.0, 0.2).with_target(NoiseTarget::Border));
}
With the Surface target, a frosted-glass surface gets grain over its blurred backdrop, a common way to add a frosted texture. Shadows, glows, inner shadows, and focus rings are not textured.
Animated Noise
#![allow(unused)]
fn main() {
use beverly::rendering::Noise;
let noise = Noise::grain(8.0, 0.1).with_animated(true).with_speed(2.0);
}
Animation scrolls the same noise field diagonally at the given speed. It does not reshuffle the pattern.
Animated noise updates its time value every frame and prevents that surface from sharing a material with identical ones, so use it on a few prominent surfaces rather than many.
Reduced-effects and reduced-motion behavior:
- Setting
UI_REDUCED_EFFECTS=1turns noise off entirely, animated or not. - The accessibility policy’s
reduced_effectsandreduced_motionflags do not change noise. They affect other animated paints, such as shimmer and spinning gradients. CheckAccessibilityVisualPolicyResourceyourself and passfalsetowith_animatedwhenreduced_motionis set, as the demo does:
#![allow(unused)]
fn main() {
use beverly::rendering::Noise;
use beverly::theme::AccessibilityVisualPolicyResource;
use bevy::prelude::*;
fn grain(policy: &AccessibilityVisualPolicyResource) -> Noise {
Noise::grain(8.0, 0.1)
.with_animated(!policy.current.reduced_motion)
.with_speed(2.0)
}
}
Updating at Runtime
Assign a new value to surface.noise:
#![allow(unused)]
fn main() {
use beverly::rendering::{Noise, Surface};
fn set_grain(surface: &mut Surface, strength: f32) {
surface.noise = Some(Noise::grain(8.0, strength));
}
}
Avoid writing an identical value each frame, which causes needless material updates. Compare first and assign only on a change.
Design Guidance
- Keep strength low. Values from about
0.03to0.15read as texture; higher values read as dirt or static. - Check contrast. Noise changes the brightness text sits on, so verify readability at the lightest and darkest parts of the grain.
- Use texture sparingly and consistently, for example on hero surfaces, frosted glass, or a deliberately tactile theme, and not on every card.
- Do not use noise as the only way to communicate state.
Debugging
Set UI_RENDER_DEBUG to inspect noise while tuning it:
UI_RENDER_DEBUG=noise cargo run --example noise
Related values are noise_coords, noise_strength, and noise_modulation. See Shaders for the full list.
Demo
Run the interactive demo from the repository root:
cargo run --example noise
cargo run --example noise -- dark
The demo has sliders for grain size (1 to 64 px), strength (0 to 100 percent), seed, and animation speed. Buttons choose the target (Surface, Fill, or Border) and switch between Still and Animated.
Previews show a gradient with a thick border, flat light, mid, and black swatches, and a frosted-glass window over colored stripes. All of them share the same settings. Animation is disabled when the reduced-motion preference is on. The top-right toggle switches the page between light and dark mode.
State Decorations
Live Demo
Open the demo in a separate tab.
A Surface can carry semantic decorations separately from its fill and border. Focus rings are rendered by Beverly’s surface shader; selection and validation currently reserve API slots but do not produce visuals.
Authored Focus Rings
Use an authored ring when a surface needs a fixed focus treatment or when building a custom control. A ring is part of Surface::decorations, independent from the ordinary surface border:
#![allow(unused)]
fn main() {
use beverly::rendering::{FocusRing, FocusRingLayer, FocusRingPlacement, Paint, Surface};
use bevy::prelude::*;
let surface = Surface::rounded_rect_fill(8.0, Paint::solid(Color::WHITE))
.with_focus_ring(
FocusRing::outside(3.0, 3.0, Paint::solid(Color::srgb(0.12, 0.42, 0.92)))
.with_placement(FocusRingPlacement::Outside)
.with_secondary(
FocusRingLayer::new(1.0, 0.0, Paint::solid(Color::WHITE))
.with_opacity(0.95),
),
);
}
FocusRing::outside(width, offset, paint) creates the primary layer. Width and offset are in logical pixels. Placement determines how the ring relates to the surface boundary:
| Placement | Result |
|---|---|
Outside | The ring sits beyond the surface edge. |
Center | The ring straddles the surface edge. |
Inside | The ring sits inside the surface edge. |
A FocusRing has one primary layer, an optional secondary layer, and an optional OuterGlow. Each FocusRingLayer has its own width, offset, opacity, and Paint. Use .with_secondary(...) and .with_glow(...) to add those optional treatments. The shader sanitizes negative or non-finite dimensions and clamps opacity to 0–1; ring width and offset are also capped to keep rendering bounds finite.
Rings and glows do not reserve layout space. Leave room outside the surface for an outside ring or glow; ancestors that clip their children may cut off that space. On the surface itself, Clip trims the focus ring, while Mask does not. See Clipping and Masking.
To change or remove an authored ring at runtime, update the surface’s decorations or clear it:
#![allow(unused)]
fn main() {
surface.decorations.focus_ring = Some(ring);
*surface = surface.clone().clear_focus_ring();
}
Automatic Focus Rings
BeverlyPlugin installs the accessibility focus-ring system. When focus moves to an entity with a Surface, Beverly writes a theme-derived ring into that surface’s decorations.focus_ring; when focus leaves it, Beverly clears the previous automatic ring. This is separate from the surface border. On a focused surface, the automatic ring replaces any authored ring in that slot.
The default theme uses the focus color for FocusIntent::Default, the info color for Selected, and the error color for Invalid. The automatic focus system currently requests the default intent. ThemeColors::focus_ring also adapts the ring to its context and accessibility visual policy:
- Normal contrast uses a 2-pixel primary ring with a 2-pixel offset. It may add a contrasting secondary ring for high-contrast mode or for Glass, Accent, and Danger surface tones.
- High-contrast mode uses a 3-pixel primary ring and chooses black or white based on the background. It also adds a contrasting secondary layer and suppresses the glow.
- In normal contrast, a theme-colored outer glow is included unless reduced effects are enabled.
Focus visibility is controlled by FocusVisibilityPolicy. Its default is KeyboardOnly, so the ring appears when focus is visible from keyboard navigation. Always and Programmatic show the ring for any focused entity; Hidden suppresses it. The policy is initialized from UI_FOCUS_VISIBILITY=always, programmatic, or hidden; unset or unrecognized values use KeyboardOnly. High contrast and reduced effects can be requested at startup with UI_HIGH_CONTRAST=1 and UI_REDUCED_EFFECTS=1.
Keyboard navigation requires a TabGroup and focusable elements with TabIndex; Beverly’s built-in Tabs component handles roving focus within its tab list. See Keyboard Navigation. The demo combines the automatic ring on built-in tabs with authored ring controls and placement comparisons.
Reserved Decoration Slots
Decorations also provides selection and validation fields, with SelectionDecoration and ValidationDecoration types. They are reserved semantic slots and are not rendered yet. Setting either value does not draw a selection outline, validation border, or other indicator. Until those renderers exist, represent selected or invalid state with an explicit surface fill, border, text, or an authored ring.
Demo
Run the interactive example from the repository root:
cargo run --example state_decorations
cargo run --example state_decorations -- dark
Use Tab to enter the tabs, then the arrow keys to switch the active tab. The authored-ring controls change placement, intent color, width, offset, secondary layer, and glow. The three comparison swatches keep their Outside, Center, and Inside placements while following the selected width and color.
Shadows
Live Demo: Outer Shadow
Open the demo in a separate tab.
Live Demo: Inner Shadow
Open the demo in a separate tab.
Shadows help show elevation and surface separation. They are best when subtle and consistent, and they should support the interface without becoming decoration. In a well-designed system, shadows are not a visual gimmick; they are a way to communicate depth, layering, and separation in a controlled, readable way.
What shadows communicate
A shadow is one of the clearest ways to tell a user that one surface sits above another. It helps the eye understand:
- which panel is floating
- which control is active or elevated
- which content is layered above the background
- which overlay needs stronger separation from the page beneath it
This makes shadows a useful tool for hierarchy, especially in dense or highly structured interfaces.
Drop shadow sizes
Beverly keeps a small set of shadow presets so surfaces can communicate depth in a predictable way.
Small
Use a small shadow for light lift: compact cards, hover states, quick actions, and other subtle surfaces that should feel slightly separated from the background.
Regular
Use the regular shadow for the default elevated surface. This is the standard choice for cards, property panels, and other content that needs a clear separation but should still feel understated.
Large
Use the large shadow for major floating overlays: modals, drawers, menus, and panels that need a strong sense of separation from the page beneath them.
Example
#![allow(unused)]
fn main() {
use beverly::rendering::{OuterShadow, Paint, Surface};
use bevy::prelude::*;
fn elevated_surface(parent: &mut ChildSpawnerCommands) {
parent.spawn((
Node {
width: Val::Px(240.0),
height: Val::Px(120.0),
..default()
},
Surface::rounded_rect_fill(8.0, Paint::solid(Color::WHITE))
.outer_shadow(OuterShadow::regular(Color::BLACK)),
));
}
}
BeverlyPlugin installs the shader-backed surface renderer. Surface::outer_shadow adds a drop shadow outside the surface’s shape without changing its layout dimensions.
Preset Values
Each preset accepts a shadow color and uses smooth falloff with zero spread:
| Constructor | Offset X / Y | Blur | Opacity |
|---|---|---|---|
OuterShadow::small(color) | 0 / 2 px | 4 px | 0.075 |
OuterShadow::regular(color) | 0 / 8 px | 16 px | 0.15 |
OuterShadow::large(color) | 0 / 16 px | 48 px | 0.175 |
These are Bootstrap-style presets. They are a convenient starting point, not a requirement to use the same shadow on every surface.
Custom Drop Shadow
#![allow(unused)]
fn main() {
use beverly::rendering::{OuterShadow, Paint, ShadowFalloff, Surface};
use bevy::prelude::*;
let shadow = OuterShadow::new(Color::BLACK)
.with_offset(Vec2::new(0.0, 12.0))
.with_blur(24.0)
.with_spread(0.0)
.with_opacity(0.35)
.with_falloff(ShadowFalloff::Smooth);
let surface = Surface::rounded_rect_fill(8.0, Paint::solid(Color::WHITE))
.outer_shadow(shadow);
}
| Parameter | Effect |
|---|---|
color | Shadow tint; black is the usual neutral choice |
offset | Logical pixel displacement; positive X moves right, positive Y moves down |
blur | Edge softness in pixels; larger values produce a broader soft edge |
spread | Expands the shadow silhouette when positive, contracts it when negative |
opacity | Shadow strength from 0.0 to 1.0 |
falloff | How the shadow fades away from its silhouette |
OuterShadow::new(color) starts with zero opacity, so set .with_opacity(...) to make a custom shadow visible. Blur and falloff are shader parameters, not a CSS box-shadow declaration; compare the rendered result rather than expecting pixel-identical browser output.
Falloff
ShadowFalloff::Linear: a linear fade.ShadowFalloff::Smooth: a smooth fade, used by the presets.ShadowFalloff::Gaussian: a Gaussian-style fade.
Falloff changes the fade profile, independently of the shadow’s offset and spread.
Runtime Updates
Change surface.effects.outer_shadow to update a shadow at runtime:
#![allow(unused)]
fn main() {
use beverly::rendering::{OuterShadow, Surface};
use bevy::prelude::*;
fn set_shadow_strength(surface: &mut Surface, opacity: f32) {
surface.effects.outer_shadow = Some(
OuterShadow::regular(Color::BLACK)
.with_opacity(opacity.clamp(0.0, 1.0)),
);
}
}
Use surface.clear_outer_shadow() to remove it. A surface has one outer-shadow slot; setting another shadow replaces the previous one.
Leave space around the surface for the shadow to render. A clipping ancestor can cut off the effect, and shadows do not reserve space between neighboring elements automatically.
Explicit shadow colors do not change automatically with the theme. Black shadows are less visible on dark backgrounds; use an appropriate surface fill and a subtle border to maintain separation rather than relying on the shadow alone.
Demo
Run the interactive drop-shadow demo from the repository root:
cargo run --example outer_shadow
cargo run --example outer_shadow -- dark
The demo includes horizontal and vertical offset sliders, softness, spread, and opacity controls, plus Linear, Smooth, and Gaussian falloff selectors. The main preview uses the selected parameters; the Small, Regular, and Large samples retain their preset shadows for comparison.
The top-right toggle switches light/dark mode. Surface fills and borders follow the theme while the shadows remain black.
Inner Shadows
InnerShadow paints shading inside a surface’s shape, making it appear recessed rather than elevated. Use it for inset trays, pressed surfaces, and recessed controls. It is a shader-backed surface effect, not a separate child node or a background-blur operation.
#![allow(unused)]
fn main() {
use beverly::rendering::{InnerShadow, Paint, ShadowFalloff, Surface};
use bevy::prelude::*;
fn add_recessed_tray(parent: &mut ChildSpawnerCommands) {
parent.spawn((
Node {
width: Val::Px(300.0),
height: Val::Px(180.0),
padding: UiRect::all(Val::Px(40.0)),
..default()
},
Surface::rounded_rect_fill(8.0, Paint::solid(Color::WHITE))
.inner_shadow(
InnerShadow::new(Color::BLACK)
.with_offset(Vec2::new(0.0, 8.0))
.with_blur(16.0)
.with_spread(4.0)
.with_opacity(0.45)
.with_falloff(ShadowFalloff::Smooth),
),
));
}
}
BeverlyPlugin includes the renderer required for this example. The shading follows the surface’s rounded corners; it does not change the node’s size or reserve padding for its contents.
Inset Parameters
The inner-shadow API uses the same parameter names as OuterShadow:
| Setter | Purpose |
|---|---|
with_offset(Vec2) | Displace the shadow in logical UI pixels; X points right and Y points down |
with_blur(pixels) | Adjust the softness of the inset shading |
with_spread(pixels) | Adjust the inset shadow’s spread; signed values are supported |
with_opacity(value) | Set strength from 0.0 to 1.0 |
with_falloff(mode) | Choose Linear, Smooth, or Gaussian fade |
The constructor’s color sets the shadow tint. InnerShadow::new(color) defaults to zero opacity, so set an opacity explicitly or use a preset. Compare inset spread visually rather than assuming it expands like an outer shadow: the shading is constrained to the inside of the shape.
Inset Presets
All three presets use zero spread and smooth falloff:
| Constructor | Offset X / Y | Blur | Opacity |
|---|---|---|---|
InnerShadow::small(color) | 0 / 1 px | 2 px | 0.075 |
InnerShadow::regular(color) | 0 / 2 px | 4 px | 0.125 |
InnerShadow::large(color) | 0 / 4 px | 8 px | 0.175 |
#![allow(unused)]
fn main() {
use beverly::rendering::{InnerShadow, Paint, Surface};
use bevy::prelude::*;
let surface = Surface::rounded_rect_fill(8.0, Paint::solid(Color::WHITE))
.inner_shadow(InnerShadow::regular(Color::BLACK));
}
Updating and Combining
Update surface.effects.inner_shadow at runtime, or call surface.clear_inner_shadow() to remove it. Each surface has one inner-shadow slot; a new value replaces the existing inset shadow.
Inner and outer shadows occupy separate slots, so a surface can use both. Borders and fills remain independently configurable. Explicit shadow colors are not automatically replaced when the theme changes.
Keep text clear of heavy edge shading with layout padding. In dark mode, black inset shading can become subtle; preserve readable surface contrast and do not use the shadow as the only indication of focus, selection, or disabled state.
Inner Shadow Demo
Run the interactive inset-shadow demo from the repository root:
cargo run --example inner_shadow
cargo run --example inner_shadow -- dark
The demo includes horizontal and vertical offsets, softness, spread, and opacity controls, plus three falloff selectors. The main recessed tray follows these settings while Small, Regular, and Large samples retain their preset shadows.
The top-right toggle switches light/dark mode. Surface fills and borders follow the theme while the shadows remain black.
Common uses
- cards and panels
- modal shells and overlays
- hover states on floating controls
- tooltips and context menus
- data surfaces that need a clear sense of separation from the background
Good shadow behavior
A strong shadow system should be:
- consistent across the app
- tuned to the actual elevation scale
- subtle enough to preserve readability
- distinct enough to communicate hierarchy
- easy to adjust for dark and light themes
The goal is not to create heavy, dramatic shadows everywhere. It is to establish a clear system of depth that feels calm and intentional.
Avoiding over-shadowing
Too many strong shadows make the interface feel noisy and visually crowded. If every card and panel has a large visible shadow, the hierarchy becomes muddy and the app loses calmness.
This is why Beverly should favor a small set of preset values rather than a free-form shadow system that encourages each component to tune itself independently. A restrained preset model makes the product feel coherent.
Shadows and accessibility
Shadows are not a replacement for contrast or focus states. They should support a surface, not hide a problem. In a high-contrast or reduced-visual-noise environment, a strong shadow may become too heavy or unnecessary.
That means shadow intensity should be part of the design policy layer, not just a static decoration value. The system should be able to reduce or suppress certain effects when accessibility or theme constraints require it.
The principle
Use a single shadow system with a small set of presets so the app stays coherent across components. Pick the lightest shadow that still communicates the right depth. The best shadows are the ones users do not consciously notice because they make the structure clearer without interrupting the content.
Glow
Live Demo
Open the demo in a separate tab.
OuterGlow adds a shader-rendered halo outside a Surface. Unlike a drop shadow, it has no directional offset: the glow surrounds the surface’s shape.
Use it for restrained emphasis, luminous accents, or an active surface. BeverlyPlugin installs the renderer required for these examples.
Basic Usage
#![allow(unused)]
fn main() {
use beverly::rendering::{OuterGlow, Paint, ShadowFalloff, Surface};
use bevy::prelude::*;
fn add_glowing_surface(parent: &mut ChildSpawnerCommands) {
parent.spawn((
Node {
width: Val::Px(300.0),
height: Val::Px(110.0),
..default()
},
Surface::rounded_rect_fill(8.0, Paint::solid(Color::WHITE))
.outer_glow(
OuterGlow::new(Color::srgb(0.10, 0.78, 0.48))
.with_blur(24.0)
.with_spread(4.0)
.with_opacity(0.65)
.with_falloff(ShadowFalloff::Gaussian),
),
));
}
}
The glow follows the surface’s rounded shape. It does not change its fill, resize the node, or reserve spacing around neighboring content.
Parameters
| Parameter | Effect |
|---|---|
color | Halo tint |
blur | Softness in logical pixels |
spread | Expands the halo silhouette when positive; contracts it when negative |
opacity | Effect strength from 0.0 to 1.0 |
falloff | Fade profile outside the silhouette |
OuterGlow::new(color) defaults to a 10-pixel blur, zero spread, Gaussian falloff, and zero opacity. Set .with_opacity(...) explicitly to make it visible.
Increasing blur softens the halo; increasing spread makes it extend farther before fading. For a tightly defined luminous edge, try a small blur and spread. For a softer atmosphere, use a larger blur with restrained opacity.
Falloff Modes
The glow shares ShadowFalloff with the shadow effects:
Linear: a linear fade.Smooth: a smooth fade.Gaussian: a Gaussian-style fade, used by the default glow.
Falloff changes the fade profile independently of blur and spread. These are shader effects, not CSS filter declarations; inspect the rendered result when tuning them.
Runtime Updates
Change surface.effects.outer_glow to update the effect:
#![allow(unused)]
fn main() {
use beverly::rendering::{OuterGlow, Surface};
use bevy::prelude::*;
fn set_glow_strength(surface: &mut Surface, color: Color, opacity: f32) {
surface.effects.outer_glow = Some(
OuterGlow::new(color)
.with_blur(24.0)
.with_spread(4.0)
.with_opacity(opacity.clamp(0.0, 1.0)),
);
}
}
Use surface.clear_outer_glow() to remove it. Each surface has one outer-glow slot; setting another value replaces the previous glow. Borders, outer shadows, and inner shadows are separate treatments and can coexist with it.
The effect is static unless an application system changes its parameters. If animating or pulsing it, honor reduced-motion preferences.
Layout and Theme
Leave room around a glowing surface: the effect does not reserve layout space, and a clipping ancestor can cut off the halo. Nearby glows can overlap.
Explicit glow colors remain unchanged when light/dark mode switches. Read ThemeResource when the tint should follow theme tokens. A halo usually appears stronger against a dark background, so compare both themes before choosing its opacity.
Keep text contrast sufficient against the surface fill. Glow should supplement, not replace, a visible focus ring or other semantic state indicator.
Demo
Run the interactive glow demo from the repository root:
cargo run --example glow
cargo run --example glow -- dark
The demo includes Mint, Coral, Sky, and Gold color swatches; softness, spread, and opacity sliders; and Linear, Smooth, and Gaussian falloff selectors.
The main preview follows all controls. The three reference samples use the selected color with fixed 16-pixel blur, 2-pixel spread, and Gaussian falloff:
| Sample | Opacity |
|---|---|
| Subtle | 0.2 |
| Bright | 0.5 |
| Radiant | 0.9 |
These sample names are demo comparisons, not built-in OuterGlow presets. The top-right button switches light/dark mode; surface fills follow the theme while the selected glow color remains unchanged.
Liquid Glass
Live Demo
Open the demo in a separate tab.
The liquid-glass pattern is the most advanced visual treatment in a modern UI system. It adds translucency, refraction, highlight response, and a distinctive layered feel while preserving readability. This is the style most closely associated with theblocks_studio reference work: a single Surface model is given a glass-like material, a blur treatment, and optical highlight calculations rather than a one-off hack.
Why liquid glass is special
Liquid glass is not just a transparency effect. It is a compositional surface treatment that tries to create a premium, layered, and atmospheric effect while still making the interface readable and usable. That makes it a powerful design tool when applied intentionally.
It works well for:
- modal shells and negotiation surfaces
- floating toolbars and utility panels
- premium overlays and command surfaces
- glass-like cards that need visual differentiation
The design goal is to create a feeling of depth and softness without losing the structure or legibility of the content inside the surface.
Usage
Liquid glass is the optical layer of a backdrop. Attach a Backdrop that carries a LiquidGlass to a Surface; BeverlyPlugin installs the renderer and capture pass.
#![allow(unused)]
fn main() {
use beverly::rendering::{
Backdrop, BackdropQuality, GlassProfile, LiquidGlass, Paint, Surface,
};
use bevy::prelude::*;
fn add_glass_lens(parent: &mut ChildSpawnerCommands) {
parent.spawn((
Node {
width: Val::Px(300.0),
height: Val::Px(140.0),
..default()
},
Surface::rounded_rect_fill(70.0, Paint::solid(Color::NONE))
.uniform_border(1.0, Paint::solid(Color::WHITE.with_alpha(0.5)))
.with_backdrop(
Backdrop::new()
.with_blur(2.0)
.with_tint(Color::WHITE)
.with_tint_opacity(0.06)
.with_saturation(1.15)
.with_quality(BackdropQuality::High)
.with_liquid_glass(LiquidGlass {
thickness: 8.0,
bezel_width: 16.0,
refractive_index: 1.46,
profile: GlassProfile::Squircle,
..default()
}),
),
));
}
}
The glass follows the surface’s shape. Geometry, fill, border, and shadows stay on the Surface; blur, tint, and saturation stay on the Backdrop; only the optics live in LiquidGlass. The example uses a transparent fill so the glass is the visible material.
Like any backdrop surface, it can only bend content drawn before it. See what gets blurred for the capture rules: one shared snapshot, no glass-on-glass, and no SVG icons.
Optical Parameters
LiquidGlass::default() is enabled and starts with a subtle lens. Set any field with struct update syntax (..default()).
| Field | Default | Valid range | Effect |
|---|---|---|---|
enabled | true | Turns the optical layer on or off | |
thickness | 3.0 px | 0 to 24 | How strongly the edge bends the backdrop |
bezel_width | 4.5 px | 0.5 to 48 | Width of the curved edge band; the center stays flat |
refractive_index | 1.46 | 1.0 to 1.8 | Bending strength; 1.0 bends nothing |
specular_intensity | 0.28 | 0 to 0.6 | Brightness of the directional highlight |
specular_width | 0.8 | 0.1 to 8 | Width of the highlight band |
light_direction | (-0.6, -0.8) | normalized | Where the highlight comes from; the default is the top-left |
fresnel | 0.14 | 0 to 0.4 | Brightening toward steep edges |
chromatic_aberration | 0.018 | 0 to 0.08 | Red/blue color fringing at the edge |
profile | Squircle | see below | Shape of the edge curve |
press_amount | 0.0 | 0 to 1 | Pressed-state optical response |
Values outside these ranges are clamped, and non-finite values fall back to their defaults, so an extreme setting cannot break the surface. thickness and bezel_width scale together: a thick lens with a narrow bezel produces a steep, strongly bent edge.
The bending is bounded to about a third of the bezel width, so large thickness values increase the curvature but cannot smear content across the whole surface. Increase bezel_width to give the effect more room.
Profiles
GlassProfile selects the cross-section of the edge:
Convex: a rounded dome profile.Squircle: a softer, squarer dome; the default.Concave: a recessed, meniscus-like edge.Lip: a raised rim that blends the convex and concave curves.
Try each against a background with straight lines to compare how they bend them.
Pairing with Backdrop
The glass uses the backdrop’s blur, tint, saturation, brightness, and contrast. When glass is enabled, the blur is slightly softer at the bezel than in the center, so keep with_blur small (a few pixels) for a clear lens. A heavier blur gives a frosted look instead; see Blur.
BackdropQuality::High takes the most blur samples and gives the smoothest result for a surface that is large or sits over high-contrast content.
Press Response
press_amount is a normalized value you drive from your own interaction code, for example from a button’s pressed state. It adjusts only the optical slope and shift; it never resizes the surface, its fill, its hit area, or its children.
#![allow(unused)]
fn main() {
use beverly::rendering::Surface;
use bevy::prelude::*;
fn set_press(surface: &mut Surface, pressed: bool) {
if let Some(backdrop) = surface.backdrop.as_mut() {
if let Some(glass) = backdrop.liquid_glass.as_mut() {
glass.press_amount = if pressed { 1.0 } else { 0.0 };
}
}
}
}
The press response is suppressed when the user prefers reduced motion.
Updating at Runtime
Replace surface.backdrop to change any parameter, or call surface.clear_backdrop() to remove the effect. The demo assigns a new backdrop from its sliders and skips the assignment when nothing changed.
Accessibility and Fallbacks
Beverly does not automatically replace glass with an opaque surface when reduced transparency or high contrast is requested. Only the press response is suppressed under reduced motion. Apps should check the user’s preferences themselves and remove the backdrop in favor of a solid fill and a clear border when needed.
The refraction can distort text drawn behind the glass. Keep important content inside the glass surface rather than relying on content behind it, and do not use glass to hide a focus state.
Demo
Run the interactive liquid-glass demo from the repository root:
cargo run --example liquid_glass
cargo run --example liquid_glass -- dark
The demo places a pill-shaped and a circular lens over a moving, striped background with fine lines and large text. Five sliders control thickness (0–24 px), bezel width (1–48 px), refraction (100–180%), specular intensity (0–60%), and color fringe (0–0.08); four buttons select the profile. All controls drive both lenses, and the maximum of each slider maps to the top of its valid range.
The background stays dark in both themes so the optics read consistently. The top-right toggle switches the page chrome between light and dark, and the stripes hold still when reduced motion is enabled.
Considerations
- avoid using glass on dense text surfaces unless contrast remains acceptable
- fall back to opaque or semi-opaque surfaces under high contrast or reduced transparency
- keep the effect consistent across window sizes and theme changes
- prefer a shared material pipeline over many custom glass widgets
- ensure the effect does not hide focus states or critical affordances
Performance note
Glass is visually rich but more expensive than plain fill and shadow. Use it on key surfaces such as modal shells, floating toolbars, or premium overlays, not on every single widget. This keeps the effect expressive while preventing the GPU cost from becoming a systemic problem.
Accessibility and policy boundaries
A glass surface should never be allowed to reduce usability. That means the system should respect policies such as:
- reduced transparency
- high-contrast mode
- reduced-motion preferences
- strong focus visibility
- readable text at all times
If the interface needs to fall back to a more solid treatment in those modes, it should do so without creating a separate custom version of the component. The material system should handle the variation.
The right balance
Liquid glass is best when it sits in a restrained, premium layer. It adds atmosphere, but it should not become a dominant visual element across the whole product. A single carefully chosen glass panel can feel luxurious; many of them can become visually noisy and expensive.
This is exactly why the surface model matters: it gives the framework a disciplined way to apply glass treatments only where they make sense. The effect stays expressive, but the product remains coherent and usable.
The key idea
Liquid glass is a strong example of design expression built on top of a disciplined material system. It is visually rich, but it only works well when the rest of the product remains structurally clear. That is the difference between a premium product surface and a distracting aesthetic layer.
Input
Input treatment covers both pointer and keyboard interaction semantics. Beverly should treat input as a shared interaction layer rather than a collection of random widget behaviors. This is the layer where user intent becomes real application action: text entry, selection, activation, keyboard traversal, and pointer-driven interaction all converge.
What input means in a UI architecture
Input is not just an event system. It is the way the interface translates user actions into state changes. In a Bevy-style ECS model, the interaction layer should be organized around a few clear concepts:
- pointer state such as hover, press, drag, and release
- keyboard state such as focus and key events
- text entry and editing state
- selection and activation state
- structural context such as the active form, panel, or list
These concerns should remain separate enough to be reasoned about but connected enough to work as a coherent interface.
Interaction principles
- pointer and keyboard input should have equivalent outcomes where possible
- focus should be stable and visible during editing
- input areas should provide immediate and legible feedback
- interaction rules should be consistent across text, selects, search boxes, and forms
- input state should not be hidden inside a widget without a clear system-wide pattern
Example
#![allow(unused)]
fn main() {
use bevy::prelude::*;
use beverly::prelude::*;
fn build_input_surface(mut commands: Commands) {
commands.spawn((
NodeBundle::default(),
BeverlyStack::vertical(),
)).with_children(|stack| {
stack.spawn(BeverlyInput::new("Query").placeholder("Search for files"));
stack.spawn(BeverlyButton::primary("Run search"));
});
}
}
This example shows the right way to think about input: the field has a clear purpose, the interaction model is consistent, and the user can move smoothly between editing and action.
Pointer and keyboard parity
The best UI systems avoid a split between “mouse controls” and “keyboard controls.” If a control is important, it should work well with both inputs. For example:
- pressing Enter on a search field should trigger the search action
- a button should be keyboard-operable and pointer-operable
- text inputs should support focus, selection, and editing without requiring a mouse
- list and menu navigation should work with both pointer and arrow-key patterns
This improves the product for everyone, but it is particularly important for keyboard users and people working in constrained or assistive setups.
Immediate feedback
When a user interacts with a form field, the interface should ideally respond at once. That feedback may include:
- focus ring or border emphasis
- value preview or inline validation
- hover or pressed visual state
- status messaging when a search or request is running
A good interaction layer tells the user “this input is active,” “this value changed,” or “this action has started.” Without that feedback, users can feel uncertain or lose trust in the system.
Input consistency across widgets
Beverly should keep a shared interaction model across input surfaces so the user does not need to learn a new pattern for each component. Search, select, text input, radio, toggle, and file inputs should feel like part of the same interaction language.
That means consistent behavior around:
- focus order
- visible active states
- keyboard controls
- disabled styling
- clear labels and names
- error messaging
Guidance
Input surfaces should feel consistent and reliable. The user should not have to learn different interaction rules for each field type, and the app should support both direct editing and accessible keyboard workflows. A good interaction layer improves confidence because it is predictable, legible, and grounded in clear structure.
Focus
Focus management ensures users can navigate interfaces predictably. It is the core mechanism that allows keyboard users to understand where they are and what they can interact with at any moment. In a dynamic UI, focus is the map that keeps the user oriented while the layout changes, modals open, and content is filtered or replaced.
Why focus matters
Without a stable focus model, an interface becomes difficult to use even when the visual design is polished. Users often lose context when:
- a panel opens and focus is not moved into it
- a filtered list resets the selection unexpectedly
- a modal closes without returning the user to their original control
- a content update destroys the active element without warning
This creates a sense of disorientation and breaks the user’s workflow, especially for keyboard users and assistive technology users.
Recommended rules
- focus should be visible on every interactive element
- dialog and overlay focus should move into the relevant container
- returning from a modal or panel should restore the triggering context
- dynamic content updates should not cause focus to vanish unexpectedly
- focus order should reflect the logical structure of the interface
- focus should be recoverable after state refreshes and filtered content changes
Example
#![allow(unused)]
fn main() {
use bevy::prelude::*;
use beverly::prelude::*;
fn open_panel(mut commands: Commands, mut focus: FocusState) {
let previous = focus.current();
commands.spawn(BeverlyDrawer::new("Preferences")
.on_close(move |world| {
if let Some(el) = previous {
world.focus(el);
}
}));
}
}
This pattern keeps the user in context. The focus does not disappear into a black box; it is intentionally restored to the element that triggered the panel.
Focus in dynamic interfaces
A real application is always changing. Panels open, lists filter, data refreshes, and overlays appear. Good focus management does not assume the interface is static. Instead, it keeps the active element meaningful as the system updates around it.
This is especially important in Bevy-like ECS systems where entities are created and destroyed as part of normal UI lifecycle behavior. Focus transitions should be predictable, not accidental.
Guidance
Focus is not just a visual ring. It is the path that keeps the user oriented in a system of changing panels, filtered lists, and modal interactions. A focus-aware interface feels stable and trustworthy because the user always knows where they are and what can be acted on next.
Keyboard
Keyboard support is a first-class requirement for Beverly interfaces. Dense, highly interactive applications are not complete unless their most important actions can be reached and operated without a mouse. Keyboard interaction is not a special mode; it is one of the primary ways users navigate, inspect, and operate a serious interface.
Core expectations
- tab order matches the visible structure of the screen
- focus is always visible and stable
- action keys support the common interactive patterns users expect
- overlays trap focus appropriately and restore it when dismissed
- arrow-key and selection patterns should work for lists, menus, and grouped controls
- keyboard semantics should match the interface structure rather than rely on hidden behavior
Example
#![allow(unused)]
fn main() {
use bevy::prelude::*;
use beverly::prelude::*;
fn build_keyboard_action_row(mut commands: Commands) {
commands.spawn(BeverlyToolbar::new()).with_children(|toolbar| {
toolbar.spawn(BeverlyButton::primary("Save"));
toolbar.spawn(BeverlyButton::secondary("Cancel"));
toolbar.spawn(BeverlyButton::subtle("More"));
});
}
}
This pattern keeps actions simple, visible, and keyboard-operable. The user can move through them in a predictable order without depending on pointer hover or hidden shortcuts.
Designing for keyboard users
Keyboard interaction should feel natural and efficient. That means the interface should respect common conventions such as:
- Tab to move through interactive controls
- Shift+Tab to move backward
- Enter and Space to activate buttons or controls
- Escape to close overlays or dismiss transient states
- arrow keys to move through lists, menus, or grouped options
These expectations are not optional decorations. They are basic interaction patterns that users rely on for confidence and speed.
Why keyboard support matters in Bevy UI
In a scene-driven, data-driven UI such as Bevy, it is easy to focus on what is displayed and forget that the interface must also maintain a navigable interaction model. The layout system may know where widgets are positioned, but the interaction layer must still define the actual path a user takes through them.
This makes keyboard support a structural concern, not just a final polish pass. Good keyboard flow depends on semantic structure, focus order, and stable state management.
Guidance
Keyboard support should feel natural and predictable. A user should be able to navigate through panels, menus, and simple dialogs without learning custom hidden gestures or relying on hover states. Great keyboard support reduces friction because it makes the system feel controllable in a way that matches the user’s expectations.
Pointer
Pointer handling aligns hover, press, and drag behaviors across widgets. It is the layer that turns pointer movement and clicks into meaningful interaction state for the UI. In a framework like Beverly, pointer behavior should remain predictable and consistent across widgets so users do not need to relearn the same fundamentals in each component.
Core pointer behaviors
The base pointer model usually includes:
- hover state while the cursor is over an interactive surface
- press state while the pointer is held down on a control
- click or activation when the pointer is released on the same control
- drag and move tracking for reordering, resizing, or moving content
- pointer leave and cancel handling when actions are interrupted
These states should be handled consistently so that a button, card, menu item, or custom control feels like part of the same interaction system.
Hover and press feedback
Hover and press states are not just decorative; they help users understand that the interface is responding to their input. A widget should show that it is interactive without becoming distracting.
Common examples include:
- subtle fill or border changes on hover
- slight elevation or emphasis during press
- visual feedback for active selection or drag targets
- immediate states that match active or disabled conditions
The goal is not to overdo the effect, but to provide enough feedback that the action is legible.
Consistency across widgets
A pointer should behave similarly across the app. If a button highlights on hover, a card-action area or nav item should generally follow the same rules. That consistency is one of the main reasons the interaction layer exists as a shared system instead of scattered widget-specific logic.
This helps users build a mental model for the product. They understand that hover and press cues are a language, not a one-off feature added to a small group of components.
Pointer cancellation and reliability
Pointer handling must also account for interruption. The user may move out of a widget, leave the app, or trigger a drag that cancels the original interaction. The system should respond gracefully by clearing hover or press state rather than leaving the UI in a stale active state.
This is a key part of interaction reliability. If a pressed state remains active after the pointer leaves, the interface can look broken or behave unpredictably.
The role of the pointer layer
The pointer layer is the bridge between raw input and the app’s shared interaction state. It should convert low-level movement and click events into meaningful state transitions that other systems can consume.
That means pointer behavior and the rest of the interaction system should remain aligned. Layout, focus, selection, and rendering can all react to the same interaction state without fighting one another.
Guidance
Pointer handling should align with the broader interaction design: it should be consistent, legible, and predictable. A user should feel like the UI is responding clearly to input, not like they are fighting against hidden widget logic.
Gestures
Gestures support touch and pointer-based interactions for rich applications. They are especially important in interfaces that rely on direct manipulation, quick actions, or touch-first patterns, but they should still fit the same structural principles as the rest of the UI system.
What gestures are for
A gesture is more than a raw input event. It is a pattern of motion or multi-step action that has semantic meaning in the app, such as:
- swipe to change a panel or tab
- drag to reorder items
- pan to navigate content
- pinch or scale gestures in media or canvas interactions
- long press or hold for secondary actions
These behaviors are powerful, but they should remain predictable and consistent. A gesture should feel like part of the interface language, not a hidden mechanic whose rules only exist in a single widget.
Designing gestures carefully
Every gesture should have a clear user-facing purpose. If a gesture is not understandable or does not have a visible state, it creates friction and confusion. The design should make it obvious:
- what the gesture does
- when it is active
- whether it is safe to trigger
- how to cancel or reverse it
This matters even more in touch interfaces because gestures may be harder to discover than a visible button or control.
Gesture safety and reversibility
Because gestures often map to direct manipulation, they should be designed carefully around safety and undoability. For example:
- dragging to reorder items should feel reversible or inspectable
- swipe actions should not permanently trigger without confirmation when high-impact
- touch actions should not accidentally trigger if the user is simply scrolling the page
- the system should clearly distinguish between selection gestures and destructive gestures
The more direct the gesture, the more important it is that the interface remains understandable and recoverable.
Gesture patterns in design systems
The best design systems use gestures as part of a small, well-understood set rather than inventing custom patterns everywhere. This allows the product to remain coherent and reduces the learning curve for users.
Examples of sane gesture use include:
- horizontal swipe between panels or tabs
- vertical drag in scrollable content
- drag and drop interactions for list reordering
- long-press for alternative commands in a controlled menu or action layer
The key is not to eliminate gestures, but to make them deliberate and readable.
Guidance
Gestures should support the interface without hiding meaning. They are best used when they reduce friction or enable natural direct manipulation, and they should always keep the user’s understanding and control in view.
Selection
Selection models describe how users pick content or options inside a view. They are one of the most important interaction patterns in data-heavy interfaces because they connect the user’s intention to the underlying app state. Selection is not only about highlighting a row or item; it is about representing choice in a way the user can understand and operate consistently.
Why selection matters
Selection is the mechanism that turns one object in a view into a meaningful current choice. It is how users say:
- this item is the one I want to inspect
- this row is active
- this option is currently chosen
- this action applies to this target
Without a clear selection model, interfaces become ambiguous or frustrating, especially in tables, lists, forms, and multi-panel workspaces.
Common selection patterns
Different interfaces need different selection models, but they should still be predictable:
- single selection for choosing one option from a set
- multi-selection for choosing several items at once
- range selection for contiguous groups in tables or lists
- active selection for the current focus or context object
- contextual selection for temporary highlight during inspection or drag states
The important thing is that the selection pattern clearly matches the UI’s purpose and remains stable as the user interacts.
Interaction behavior
A selection model should define how the user changes selection, how the app displays it visually, and whether the user can clear or modify the selection. It should also integrate with keyboard navigation, focus behavior, and pointer interaction.
Examples:
- click selects a single item
- Ctrl or Command click adds to a multi-selection set
- arrow keys move the active selection within a list
- range selection is maintained clearly when the selection is contiguous or grouped
The interface should not leave the user guessing whether the current highlight is merely hover, focus, or a real selection state.
Selection and state clarity
Selection should be legible. The user should be able to distinguish between:
- hover state
- focus state
- active selection state
- disabled or unavailable state
When these states are visually conflated, the interface becomes hard to understand. A row may appear selected when it is only focused, or a control may look active when it is not actually chosen.
This is why selection should be modeled as a first-class interaction state, not as a vague visual effect.
Selection in dense interfaces
Selection is especially important in tables, virtualized lists, and data-heavy UIs where many items may be on screen at once. In those cases, the interface must maintain consistent mapping between the user’s visible representation and the underlying data model.
A strong selection model should support:
- stable row identity even during scrolling or virtualization
- keyboard navigation without losing the selected target
- clear focus restoration when content updates
- reliable interactions for bulk actions, sorting, and filtering
Guidance
Selection should make the user’s current choice clear without introducing guesswork. A well-designed selection model keeps the interface expressive, predictable, and easy to operate, even in dense or dynamic data views.
Drag and Drop
Drag-and-drop patterns support reordering and transfers across surfaces. They are useful when the user needs to move content between areas, reorder items, or adjust layout in a direct, tactile way. But like any gesture-heavy interaction, drag-and-drop should remain clear, intentional, and easy to recover from.
Why drag-and-drop is useful
Direct manipulation is powerful because it matches the user’s mental model. Instead of navigating through menus or form fields, the user can move a thing to where it belongs. This works especially well for:
- list reordering
- file organization
- panel or card grouping
- transferring items between columns or regions
- adjusting data layout or sorting order
A good drag-and-drop interaction feels immediate and understandable because the user can see what is moving and where it will land.
The core requirements
A drag-and-drop system should provide:
- an obvious drag target
- a visible pointer or movement state while dragging
- clear drop targets or landing zones
- feedback about valid vs invalid drops
- cancellation or rollback behavior when the drag is abandoned
If the app does not clearly communicate these states, the interaction becomes frustrating and unpredictable.
Feedback and state transitions
A good drag system should tell the user what is happening at each stage:
- the item is being dragged
- the current drop target is valid
- the drop has succeeded or failed
- the item is being moved or re-ordered in the underlying data
This feedback can come through opacity changes, highlight states, ghost outlines, or a temporary insertion indicator. The important thing is that the state is visible and understandable while the drag is active.
Safety and reversibility
Because drag operations often have meaningful consequences, they should be designed with safety in mind. A user should not accidentally reorder or transfer content without seeing the result clearly.
This is especially important for:
- destructive or irreversible actions
- bulk operations on many items
- reordering in dense lists or tables
- cross-surface transfers where the destination is not obvious
If a drop is invalid, the system should make that clear without ambiguity. If a drag is canceled, the interface should return to its previous state smoothly.
Drag and drop in a structured interface
A drag system should fit naturally into the rest of the interaction model, not create an isolated special case. That means it should align with:
- pointer behavior
- hover and press feedback
- focus and selection patterns
- keyboard alternatives where possible
For example, a drag-enabled list should still allow keyboard navigation and maybe keyboard reordering or selection updates when pointer drag is not available.
Guidance
Drag-and-drop is powerful because it mirrors the user’s intention directly, but it should be structured, visible, and recoverable. When the system behaves clearly during the drag, the user can trust the interface and move through it with confidence.
Motion Overview
Motion in Beverly should do one job well: communicate change without stealing focus from the task at hand. Used sparingly, motion clarifies hierarchy, reinforces state, and preserves continuity across transitions. Used carelessly, it becomes decoration and increases cognitive load.
Beverly’s Motion APIs
BeverlyPlugin installs both UiAnimationPlugin and UiMotionPlugin. For a smaller custom setup, add the plugin that owns the behavior you need.
Beverly provides two complementary mechanisms:
- Property transitions animate a value toward a target using a duration, easing curve, and optional delay. Public target components currently cover
Surface,TextColor,Transform, and a node’s left position as a percentage. See Transitions. - Enter and exit motion uses the
UiMotioncomponent with built-in fade, fade-up, fade-down, and pop presets. See Animation.
The broader motion chapter also covers scrolling, loading, and reduced motion. These patterns should work together: animation may clarify a change, but state, labels, focus, and layout must remain understandable without it.
Motion Principles
- Tie movement to a user action or meaningful state change.
- Keep similar interactions consistent in duration and easing.
- Prefer short, direct transitions over looping or decorative movement.
- Preserve reading position, focus, and orientation as content changes.
- Ensure the resulting state is clear when motion is absent.
Choosing A Duration
Use the shortest duration that makes a change legible. Beverly’s default Transition is 140 ms with ease-out; built-in theme transition values are shorter for interaction feedback and modestly longer for modal changes. Treat these as a consistent baseline, not a requirement to animate every property.
Large layout changes should not rely on long travel distances or delayed starts. If movement competes with the content, reduce its distance or remove it.
Design Check
Before shipping a motion pattern, ask whether it communicates a real change, preserves focus and context, and remains clear when motion is reduced. If it fails any of those checks, simplify it or make the state change immediate.
Transitions
Transitions are the most common motion pattern in product interfaces. They connect one state to the next and help users understand what changed without feeling lost in the UI. A transition is not just an effect; it is one of the ways the product communicates continuity, hierarchy, and context across state changes.
Property Transitions In Beverly
Beverly represents a property transition with a target component. Insert or update that component on the entity along with the property it should animate. UiAnimationPlugin detects the target and interpolates from the current value; BeverlyPlugin already installs this plugin.
For example, a Surface can transition from its current fill to a new solid fill:
#![allow(unused)]
fn main() {
use std::time::Duration;
use bevy::prelude::*;
use beverly::animation::animation::{Easing, SurfaceTransitionTarget, Transition};
use beverly::rendering::{Paint, Shape, Surface};
fn highlight(commands: &mut Commands, entity: Entity) {
let target = Surface::new(
Shape::rounded_rect(8.0),
Paint::solid(Color::srgb(0.12, 0.42, 0.78)),
);
let transition = Transition::new(Duration::from_millis(180), Easing::EaseOut);
commands.entity(entity).insert(SurfaceTransitionTarget::new(target, transition));
}
}
The other public target components are TextColorTransitionTarget, TransformTransitionTarget, and NodeLeftPercentTransitionTarget. The first two accept a target value and a Transition; the node-left target accepts a percentage. For Bevy UI nodes, transform translation is interpreted in logical pixels and the supported transform animation is 2D translation, scale, and Z rotation. Non-UI entities retain full 3D Transform animation.
Transition::new(duration, easing) starts with no delay and the Decorative motion class. Use .with_delay(...) for a deliberate stagger, and .with_motion_class(MotionClass::Semantic) for a transition whose movement conveys essential state rather than decoration. Easing options include Linear, EaseIn, EaseOut, EaseInOut, Smooth, and CubicBezier.
When a new target interrupts a running transition, interpolation continues from the current value rather than snapping back to the original start. Setting the target equal to the current value completes immediately.
What transitions should do
A transition should serve one of three goals:
- show that a change has occurred
- preserve context during a layout shift
- orient the user toward the next logical action
This means transitions are usually short, smooth, and tied to a meaningful state change such as an expanded panel, a selected tab, or a newly visible alert.
A transition that does not help the user understand the interface is usually just visual noise.
Good transition qualities
- subtle and directional, not distracting
- consistent across similar UI states
- tied to a clear semantic change
- readable at a glance even without animation
- short enough to feel responsive
Typical motion patterns
Panel and drawer motion
Panels should move in a controlled way that suggests hierarchy. The movement should feel steady and maintain the user’s attention on the content, not on the motion itself.
This is especially important when opening a drawer, expanding a panel, or switching between primary and secondary views. The motion should preserve the sense that the user is still in the same product rather than being displaced into a separate visual experience.
Tab and state switches
When switching between views or modes, the UI should preserve the relationship between the old and new state. Content can crossfade or shift slightly, but it should not feel like a separate visual event.
The goal is clarity: the user should understand what changed and where they are now without losing orientation.
Lists and collections
Insert, remove, and reorder actions should remain legible. A list item entering or leaving should not create sudden jumps that make the layout hard to track.
This is especially important in dynamic data surfaces where inserts, filters, and ordering changes happen frequently. The transition should help preserve continuity, not make the list harder to follow.
Timing guidance
Keep transitions compact and predictable:
- short change: 120-200 ms
- medium change: 200-300 ms
- larger layout reflow: 300-400 ms
Avoid long easing curves or oversized travel distances. The motion should feel like a subtle explanation, not a spectacle.
Accessibility note
Transitions should still be understandable when animation is disabled. If a panel is shown or hidden, the content should remain readable and focus should land predictably. Layout changes should not trap users or reset context unexpectedly.
A transition is not complete if it depends on the animation to preserve meaning.
Beverly’s property-animation system checks AccessibilityVisualPolicyResource. When reduced_motion is enabled, decorative transitions resolve immediately (duration and delay become zero); semantic transitions retain their configured timing. The policy is initialized from the UI_REDUCED_MOTION environment variable (1, true, yes, or on enables it), and applications can also update the resource at runtime. Beverly does not automatically read an operating-system preference in this runtime.
Rule of thumb
If the interface still communicates the state clearly without animation, the motion is probably too elaborate. Aim for confidence, not flourish.
Animation
Animation is useful when a small, purposeful movement makes a system response feel immediate and understandable. Beverly favors restrained motion over expressive, loop-heavy effects. The best animations in a design system feel like feedback, not performance.
Enter And Exit Motion
Use UiMotion when an entity should animate as it appears or before it is removed. UiMotion::fade(), UiMotion::fade_up(), and UiMotion::pop() provide paired enter and exit settings; UiMotion::new and UiMotionSpec let you choose each side separately.
#![allow(unused)]
fn main() {
use bevy::prelude::*;
use beverly::animation::motion::{UiMotion, UiMotionExitRequested};
fn spawn_panel(mut commands: Commands) {
commands.spawn((
Node::default(),
UiMotion::fade_up(),
));
}
fn close_panel(mut commands: Commands, panel: Entity) {
commands.entity(panel).insert(UiMotionExitRequested);
}
}
The enter animation begins when UiMotion is added. To play it again, call restart_enter() on the component. To animate removal, add UiMotionExitRequested; the exit phase then completes and despawns the entity and its children. An entity without an exit spec is removed immediately when an exit is requested.
Presets affect opacity for solid Surface fills and TextColor, and use the node’s top margin for vertical movement. The Pop preset uses a small vertical offset; it is not a scale animation. Configure enter and exit independently with UiMotionSpec::fade, fade_up, fade_down, or pop, and optionally add a delay with .delay(seconds).
Appropriate uses
Animation is valuable for:
- button press and release feedback
- active selection or status changes
- indicator movement tied to state
- safe micro-interactions that reinforce action without interrupting the task
- confirmation that a state has changed or a task is underway
It is not ideal for decorative effects that do not reveal state, structure, or intent.
Design guidance
- Keep animation physically plausible and visually light
- Prefer easing that feels measured and calm
- Avoid frequent or repeated motion that competes with content
- Let the animation reinforce the action, not replace clarity
- Keep animation durations aligned with the broader motion system
Motion categories
Micro-interactions
Micro-interactions should feel responsive and immediate. A press state, hover reveal, or selection pulse should be quick enough that the user perceives the system as responsive rather than delayed.
A micro-interaction should answer the question, “Did the system receive my input?” without creating a separate entertainment layer.
Status and feedback
When something changes, such as a success state or an active filter, animation can help confirm a result. The motion should read as confirmation, not as a separate event.
This makes animation a communication tool rather than a purely aesthetic one.
Continuous motion
Continuous motion is acceptable only when it is informative. Progress indicators, status pulses, and subtle ambient motion can support awareness, but they should never become the primary focus of the interface.
In a professional product, continuous decorative motion is usually a sign that the animation is doing too much.
Practical limits
Avoid:
- looping animations on static content
- large objects moving across the viewport for no reason
- repeated bounce or wobble patterns
- motion that makes reading difficult or makes controls feel unstable
- long, accidental delays that create the feeling of lag
The best motion is brief, clear, and aligned with the action it supports.
Reduced-Motion Behavior
UiMotion enter/exit presets currently run independently of AccessibilityVisualPolicyResource; they are not automatically suppressed when reduced motion is enabled. If your application uses these presets, check the policy before attaching UiMotion or provide an immediate/static alternative. Property transitions have separate policy behavior described in Transitions.
Do not assume Beverly automatically detects the platform’s prefers-reduced-motion setting. The shared policy can be initialized using UI_REDUCED_MOTION or set directly by the application. A state change must remain understandable without the visual flourish.
Scrolling
Scrolling is one of the most common user interactions in a UI system, so it should feel stable, predictable, and respectful of reading flow. The best scrolling behavior makes long content feel natural, not fragile. It preserves the user’s place and supports reading, navigation, and focus without introducing friction or sudden context loss.
Goals
Good scrolling behavior should:
- maintain a clear reading position
- allow users to navigate large content sets without loss of context
- feel smooth without introducing jank or oversensitivity
- support keyboard, touch, and pointer navigation consistently
- preserve focus and heading context when content updates underneath the viewport
Expectations
- The scroll position should remain understandable and recoverable
- Large content areas should not unexpectedly jump when content updates
- Nested layouts should preserve the user’s attention without abrupt resets
- Sticky headers and focus targets should not fight the scroll rhythm
- Heavy content changes should not force an unrelated jump to the top of the surface
Content-aware behavior
Scrolling decisions should depend on the surface:
- list views should prioritize predictable item flow
- dashboards should keep critical controls visible without overblocking content
- detail views should preserve focus and heading context during updates
- workspace panels should remain stable while the user is navigating through context-rich data
When content changes under the user, prefer preserving the current context instead of forcing a jump to the top. This is a subtle but critical part of a calm interface.
Performance and smoothness
Motion is not just visual; it is also a perception problem. Smooth scrolling depends on predictable layout, limited reflow, and careful handling of expensive effects. Beverly should avoid heavy blur, expensive shadows, and large visual layers on frequently scrolling surfaces when a simpler treatment is enough.
This is especially important in data-heavy interfaces: a smooth list with stable composition feels premium, while a jittery scroll state makes the product feel fragile even if the data is logically correct.
Accessibility and control
Users should remain in control of scrolling speed, momentum, and focus. Avoid motion that hijacks the viewport or introduces surprising snap behavior. If motion is reduced, the system should still allow safe and predictable navigation even when animation is toned down.
This means scrolling should not fight the user. It should support orientation, reading flow, and confidence.
Rule of thumb
Scrolling should support reading and orientation. If it calls attention to itself, it is doing too much.
Loading
Live Demo: Progress Bar
Open the demo in a separate tab.
Live Demo: Spinner
Open the demo in a separate tab.
Progress Bar API
BeverlyPlugin installs the progress bar systems. Spawn a bar inside a UI parent
with spawn_progress_bar_into; progress ranges from 0.0 to 1.0.
#![allow(unused)]
fn main() {
use beverly::components::progress_bar::{ProgressBar, spawn_progress_bar_into};
use bevy::prelude::*;
fn add_progress(parent: &mut ChildSpawnerCommands) -> Entity {
spawn_progress_bar_into(parent, ProgressBar::new().progress(0.56).size(300.0, 12.0))
}
}
Update the returned entity’s ProgressBar::progress as work completes, keeping
the value in range. The visual fill animates toward the target. Keep a visible
label or percentage beside the bar; the example supplies these separately.
Spinner API
The spinner example uses the bundled Feather loader icon at three sizes, not a
separate spinner builder. Beverly’s icon systems animate the loader. Pair it
with status text for work whose completion percentage is unknown.
#![allow(unused)]
fn main() {
use beverly::icons::IconCommands;
use bevy::prelude::*;
fn add_activity(parent: &mut ChildSpawnerCommands) {
parent.spawn_feather_sized("loader", 32.0);
parent.spawn(Text::new("Loading content"));
}
}
Loading states should reassure the user that progress is happening without creating unnecessary motion. They should answer one question clearly: “What is happening now, and how far along is it?” A loading state is not just a spinner; it is a statement about the app’s current state and the user’s expectation of continuity.
Good loading patterns
Use loading patterns to indicate:
- a request is in flight
- content is being prepared
- a task is waiting on external state
- the UI is temporarily unavailable but remains in context
The best loading states maintain orientation and avoid surprising the user.
Recommended behaviors
- prefer lightweight, non-blocking feedback for short actions
- use skeleton or placeholder structure for larger content surfaces (see Loading Skeleton)
- show progress when a task has a meaningful duration or expected completion
- keep the existing layout stable so the user does not lose their place
- explain what is loading when the state is not otherwise obvious
A loading state should reduce uncertainty, not create more of it.
Avoid
- flashy progress ornaments that are unrelated to the actual task
- large full-screen loaders for small, local transitions
- motion that makes a loading state look more important than the content underneath
- reliance on animation alone when a textual status or label would be clearer
- false precision when the app does not actually know how much progress is complete
Progress vs. activity
If progress cannot be measured, emphasize activity without implying precision. A subtle pulse or spinner is acceptable for background work, but it should not pretend to represent a concrete completion percentage.
This distinction matters because users interpret loading states as signals of trust and reliability. Honest states are much better than decorative ones.
Loading and layout stability
A good loading UI usually preserves structure. Instead of replacing the entire screen with a generic loader, the interface may:
- show a localized loading state in the relevant panel
- keep the page layout intact while content is refreshed
- use placeholder content that hints at the final structure
- preserve the user’s place in the list or form while data is arriving
This makes the app feel stable and prevents the user from losing context during an asynchronous update.
Reduced motion treatment
In reduced-motion modes, loading cues should become quieter and more static. A spinner may soften or disappear, while labels, placeholders, and status text carry the clarity. The key is to retain confidence without decorative motion.
Loading states should still be informative even when motion is reduced; they should just become more restrained and readable.
Loading Skeleton
Live Demo
Open the demo in a separate tab.
A skeleton is a placeholder silhouette that stands in for content while it loads. It keeps the layout stable and shows where content will appear. Skeleton is decorative and non-interactive, and SkeletonGroup marks a whole loading region as busy for assistive technology.
BeverlyPlugin installs SkeletonPlugin. Skeletons are not progress indicators: they say that something is loading, not how far along it is. For that, see Loading.
Basic Usage
Skeleton is a component. Spawn one with a size, and it paints itself from the theme:
#![allow(unused)]
fn main() {
use beverly::prelude::*;
use bevy::prelude::*;
fn add_placeholders(parent: &mut ChildSpawnerCommands) {
// A circle for an avatar.
parent.spawn(Skeleton::circle(56.0));
// A text line that follows the theme's body type size.
parent.spawn(Skeleton::text().width(Val::Percent(55.0)));
// A block with an explicit size and corner radius.
parent.spawn(
Skeleton::new()
.width(Val::Percent(100.0))
.height(Val::Px(140.0))
.radius(8.0),
);
}
}
A skeleton adds a Node and a Surface for you and never replaces a Node you provide. Your margin, positioning, flex settings, and clipping are kept. Only the width and height you set on the skeleton are applied.
Skeletons ignore pointer input, so they never block clicks on anything behind them.
Constructors
| Constructor | Shape |
|---|---|
Skeleton::new() | A rounded block with a 6 px radius. It leaves your Node size untouched |
Skeleton::text() | A text line with a 3 px radius and a height taken from the theme |
Skeleton::circle(size) | A fixed size by size circle |
Text height
Skeleton::text() sets its height to the theme’s body font size times 1.25 when you don’t give it one, so a line placeholder matches a real line of text. The height tracks the theme live. If you set .height(...) on the skeleton, or a Node height of your own, yours is kept.
Text Blocks
Skeleton::text_lines(count) builds a labeled group of text lines in one step:
#![allow(unused)]
fn main() {
use beverly::prelude::*;
use bevy::prelude::*;
fn loading_paragraph(mut commands: Commands, parent: Entity) {
let group = Skeleton::text_lines(3)
.label("Loading description")
.width(Val::Percent(100.0))
.gap(8.0)
.final_width(Val::Percent(60.0))
.spawn(&mut commands);
commands.entity(parent).add_child(group);
}
}
| Setter | Effect |
|---|---|
label(text) | The accessible name of the loading region. Defaults to “Loading content” |
width(val) | Width of the group |
gap(pixels) | Space between lines. Defaults to 8 px |
final_width(val) | Makes the last line shorter, like the end of a paragraph |
line(skeleton) | Shared styling for every line, such as direction or phase |
node(node) | Replaces the whole group layout |
spawn returns the group entity, so you can attach it to an existing parent.
Loading Regions
Wrap any number of skeletons in a SkeletonGroup to announce one loading region instead of many separate shapes:
#![allow(unused)]
fn main() {
use beverly::animation::skeleton::SkeletonGroup;
use beverly::prelude::*;
use bevy::prelude::*;
fn loading_card(parent: &mut ChildSpawnerCommands) {
parent
.spawn(SkeletonGroup::new("Loading biscuit delivery"))
.with_children(|group| {
group.spawn(Skeleton::circle(56.0));
group.spawn(Skeleton::text().width(Val::Percent(55.0)));
group.spawn(Skeleton::text().width(Val::Percent(100.0)));
});
}
}
The group is exposed to assistive technology as one busy, polite region with your label, so a screen reader can announce it without reading each decorative shape. The individual skeletons are decorative and contribute no semantics of their own.
The group defaults to a column layout with an 8 px gap. Any layout you set on the group’s own Node wins over those defaults.
When content arrives, despawn the loading group and spawn the real content. If you keep the region and swap its children instead, call .busy(false) on the group so the busy state clears:
#![allow(unused)]
fn main() {
use beverly::animation::skeleton::SkeletonGroup;
let finished = SkeletonGroup::new("Loading biscuit delivery").busy(false);
}
Shimmer
By default a skeleton shows a soft highlight that sweeps across it. These settings control it:
| Setter | Default | Effect |
|---|---|---|
direction(dir) | LeftToRight | SkeletonDirection::LeftToRight or RightToLeft, from beverly::animation::skeleton |
duration(seconds) | 1.5 | Time for one sweep, from 0.1 to 3600 seconds |
phase(cycles) | 0 | Shifts the sweep in the cycle, so neighbors can be staggered |
highlight_width(value) | 0.22 | Width of the highlight band as a fraction of the skeleton, from 0.01 to 0.8 |
highlight_softness(value) | 0.8 | How soft the band’s edges are, from 0.05 to 1 |
highlight_intensity(value) | 0.65 | Strength of the highlight, from 0 to 1 |
enabled(bool) | true | false shows a still bar instead of a sweep |
Invalid or out-of-range values are clamped to these ranges, and non-finite values fall back to their defaults.
The sweep runs on the GPU clock, so all skeletons share one time source and stay in step without any system of your own. Use phase to offset them if you want a staggered look.
Colors
Skeletons use the theme’s skeleton_base and skeleton_highlight colors, so they follow light and dark mode automatically:
| Theme | Base | Highlight |
|---|---|---|
| Light | a pale cool grey | near white |
| Dark | a dark slate | a lighter slate |
Override them per skeleton with .base_color(color) and .highlight_color(color), or use .color(color) for the base alone. An explicit color stays fixed when the theme changes. Call .theme_colors() to remove both overrides and follow the theme again.
Accessibility
Skeletons adapt to the user’s visual preferences without any extra code:
- Reduced motion or reduced effects: the sweep is turned off and each skeleton becomes a still bar in its base color. This also applies if the theme has reduced effects enabled.
- High contrast: the sweep is turned off, and the base color switches to the theme’s muted text color to keep a strong silhouette. Your custom colors are ignored in this mode.
- Reduced transparency: the base and highlight colors are made fully opaque.
A skeleton never carries information on its own. Pair it with a SkeletonGroup label so the loading state is announced, and replace it with real content as soon as it is ready.
Design Guidance
- Match the placeholders to the shape and size of the content they stand in for, so nothing jumps when the content arrives.
- Use skeletons for larger surfaces such as cards, lists, and profiles. For a short action, a spinner or button state is usually clearer.
- Don’t leave a skeleton on screen indefinitely. If loading fails, replace it with an error message and a retry action.
- Keep the shapes simple. A few blocks that hint at the structure read better than a detailed imitation.
Updating a Skeleton
A skeleton’s settings are fixed when it is created. To change them, replace the component on the entity with a new Skeleton, or despawn and rebuild the group. The demo rebuilds its placeholders when you change the sweep controls.
Demo
Run the interactive demo from the repository root:
cargo run --example skeleton
cargo run --example skeleton -- dark
The demo shows a loading card with a circle avatar, two header lines, an image block, and three text lines inside one SkeletonGroup. Three buttons control it:
- Skeleton shows the placeholders.
- Content swaps them for the real content.
- Reload shows the skeleton for 2.5 seconds, then switches to the content.
Two more buttons set the sweep direction, and sliders set the sweep duration (0.5 to 4 seconds) and highlight intensity (0 to 100 percent). Changing a sweep setting rebuilds the placeholders.
With reduced motion or high contrast on, the placeholders appear as still bars. The top-right toggle switches the page between light and dark mode.
Reduced Motion
Accessible interfaces should respect reduced-motion preferences without losing semantic clarity. Beverly exposes a shared reduced-motion policy so applications can coordinate this behavior. The goal is not to remove all motion, but to remove the motion that is noisy, distracting, or unnecessary while preserving the meaning of the interface.
Core principle
When a user has requested reduced motion, the system should remove or tone down non-essential animation while preserving the meaning of the interface. The design still needs to communicate state, hierarchy, and progress clearly.
This is a user-focused principle: the application should reduce visual stimulation without making the user work harder to understand what is happening.
Recommended behavior
- disable decorative loops and repeated visual flourishes
- shorten transition durations or replace them with instant state changes
- preserve focus order and context during dynamic layout updates
- use text, labels, and structural cues to carry meaning when animation is reduced
- keep motion policies centralized so the app behaves consistently across components
Patterns that should calm down
- hover and press effects
- large panel transitions
- list insertion/removal motion
- loading indicators that are purely decorative
- scroll and reveal motion that is not essential to navigation
- any movement that adds emotional intensity without adding meaning
Patterns that should remain expressive
A user who requests reduced motion still needs clarity about:
- current state
- ongoing tasks
- errors and warnings
- focus movement
- state transitions in active panels or dialogs
These cues can remain clear without requiring elaborate animation. In many cases, static or subtle changes are more readable than a more dramatic motion sequence.
Beverly Configuration
BeverlyPlugin initializes AccessibilityVisualPolicyResource. Its current.reduced_motion value defaults to false and can be initialized from UI_REDUCED_MOTION (1, true, TRUE, yes, or on enables it). The application can also update this resource at runtime, for example in response to its own settings or platform-preference integration.
The shared policy is applied to Beverly’s Transition property animations and
UiMotion enter/exit presets: decorative property transitions collapse their
duration and delay, entering UiMotion elements settle immediately, and
requested exits are removed immediately. Decorative spinner rotation also
stops while reduced motion is enabled; the loading task itself continues so
progress and completion semantics are preserved. Other component-specific
motion should be audited as it is added. Beverly does not automatically detect
an operating-system reduced-motion preference; applications must initialize or
update the policy from their platform settings.
#![allow(unused)]
fn main() {
use bevy::prelude::*;
use beverly::theme::AccessibilityVisualPolicyResource;
fn enable_reduced_motion(mut policy: ResMut<AccessibilityVisualPolicyResource>) {
policy.current.reduced_motion = true;
}
}
A centralized setting is still valuable, but applications should not infer that every animation is disabled merely because this flag is enabled.
Rule of thumb
If motion is removed and the interface still communicates the correct state, the design is successful. Reduced motion should not reduce intelligibility; it should reduce distraction. The product should feel relaxed, not stripped of meaning.
Accessibility Overview
Accessibility is part of the core design contract, not an afterthought. Beverly treats it as a first-class product requirement: every interactive surface should be understandable by keyboard, readable in high-contrast settings, resilient under reduced-motion preferences, and labeled clearly for assistive technologies.
Core principles
- semantic structure before decoration
- visible focus and predictable focus order
- strong contrast without relying on color alone
- reduced-motion support for non-essential effects
- names, roles, and states that remain clear to screen readers
The Beverly approach
The library should make it easy to build interfaces that are robust by default. That means using thoughtful component structure, consistent state handling, and accessible defaults instead of forcing each app to bolt accessibility on later.
A well-designed app keeps the same rules across surfaces:
- actions are reachable by keyboard
- focus is always visible and stable
- status changes are announced or obvious
- motion supports preference-based reduction
- text remains legible in light, dark, and high-contrast modes
Recommended design flow
- Start with clear hierarchy and semantic labels.
- Add interactive behavior and keyboard flow.
- Verify focus indicators and focus restoration.
- Confirm contrast and motion preferences.
- Check that critical state changes are understandable without visual flourish.
Component checklist
Use the component accessibility checklist during implementation and review. It is the source of truth for the expected role, name, state, keyboard, focus, disabled, validation, relationship, and announcement behavior of each interactive component.
Every new component should add at least one headless semantic regression test, one keyboard/focus test when it is composite, and a manual screen-reader check for platform behavior that cannot be proven without a real accessibility adapter.
Semantic Relationships
SemanticNode carries a component’s role, accessible name, value, state, and
actions. Its SemanticRelationships connects that node to other UI entities.
Beverly projects supported relationships into the AccessKit tree consumed by
the platform accessibility adapter:
| Relationship | Meaning |
|---|---|
labelled_by | Another entity provides this node’s name. |
described_by | Other entities provide additional description. |
error_message | An entity contains this control’s error message. |
controls | This node controls the referenced entities. |
owns | This node owns the referenced entities in the semantic tree. |
active_descendant | The referenced entity is the active item within this control. |
Relationship values are Bevy Entity IDs, not text or DOM IDs. The reverse
controlled_by field is retained in Beverly’s semantic model, but AccessKit
has no corresponding property; set controls on the controlling node instead
so the relationship reaches assistive technology. Platform adapters and
screen readers can vary, so verify important flows with real assistive
technology as well as semantic projection tests.
For toggle buttons, set SemanticState.pressed only when reporting the
button’s persistent on/off state. It is not the momentary pointer-pressed
interaction state. checked, when present, takes precedence because both map
to the same toggle state in AccessKit.
Example: accessible app shell
#![allow(unused)]
fn main() {
use bevy::prelude::*;
use beverly::prelude::*;
fn build_shell(mut commands: Commands) {
commands.spawn((
NodeBundle::default(),
BeverlyAppShell::new(),
)).with_children(|parent| {
parent.spawn(NodeBundle::default()).with_children(|sidebar| {
sidebar.spawn(BeverlyButton::primary("Overview"));
sidebar.spawn(BeverlyButton::secondary("Reports"));
sidebar.spawn(BeverlyButton::secondary("Settings"));
});
parent.spawn(BeverlyMainPane::new()).with_children(|main| {
main.spawn(BeverlyTitle::new("Workspace"));
main.spawn(text("A keyboard-friendly dashboard surface."));
main.spawn(BeverlyButton::primary("Create report"));
});
});
}
}
This pattern keeps the hierarchy simple, the action labels explicit, and the focus path predictable.
Good accessibility is not a separate aesthetic. It is a baseline for clarity, confidence, and usability.
Keyboard Navigation
Keyboard navigation is a core interaction model, not a secondary mode. A design is not complete if a user cannot reach important actions, modify state, and move through content without a pointer.
Required behavior
All major flows should support:
- Tab and Shift+Tab for sequential movement
- Enter and Space for activation
- arrow keys for list, menu, and selection patterns
- Escape to dismiss overlays or close transient controls
- clear focus order between panels, menus, and dialogs
Layout and hierarchy
Keyboard order should follow the visual hierarchy and the semantic reading order. If a user can see a section before another, the focus order should usually follow the same flow. This reduces cognitive load and makes the interface feel stable.
When layouts become dense, such as tabs, tables, or toolbars, a component should define a predictable pattern rather than letting focus drift unpredictably based on DOM or render order.
Controls with special behavior
Some widgets require more than a simple button pattern:
- menus should support arrow-key navigation and dismissal
- tabs should move focus between tabs without losing context
- table cells and rows should have a clear grid or list pattern
- modals should trap focus until dismissed
- search and filter controls should support immediate keyboard completion without pointer assistance
Avoiding common failures
- do not rely on hover-only affordances
- do not hide interactive elements behind visual styling that only works with a mouse
- do not expose controls without labels
- do not create keyboard traps in nested panels or overlays
Example: tab-like navigation
#![allow(unused)]
fn main() {
use bevy::prelude::*;
use beverly::prelude::*;
fn build_tabs(mut commands: Commands) {
commands.spawn((
NodeBundle::default(),
BeverlyTabs::new(),
)).with_children(|tabs| {
tabs.spawn(BeverlyTab::new("Overview").active(true));
tabs.spawn(BeverlyTab::new("Activity"));
tabs.spawn(BeverlyTab::new("Settings"));
});
}
}
A tab system should support arrow-key movement, visible selection state, and a predictable focus order so users can move between panels without a pointer.
Good default rule
If a user can understand the interface, they should also be able to operate it with a keyboard. A Beverly UI should feel equally intentional in both pointer and non-pointer workflows.
Focus Management
Focus management keeps users oriented when an interface changes, opens panels, or updates content dynamically. In practice, a good focus model is predictable, visible, and easy to recover from when the interface state changes.
Design rules
- Every interactive control should have a clear focus state.
- Focus should move in a logical order that matches the visual and reading order.
- When a modal appears, focus should move into it and return on close.
- When content is inserted or replaced, focus should land on the most relevant next element.
- Focus should never disappear because a widget re-rendered.
Recommended behavior
A Beverly pattern should keep the active element stable while still allowing the app to respond to user intent. For example, when a dialog opens, the first actionable element should receive focus. When the dialog closes, focus should return to the triggering control unless the interface clearly indicates a different destination.
Dynamic panels, drawers, tabs, and inline editors should preserve context. If the user is deep in a workflow, an app should not reset them to the top of the page or trap them in a visually hidden state.
Visual focus
The focus indicator should be strong enough to stand out without being overly decorative. In dark and light themes, it should remain visible across surfaces, not disappear on glass panels or translucent containers.
A focus treatment should be:
- high enough contrast to be unmistakable
- consistent across controls
- applied to keyboard focus, not just pointer hover
- easy to read against gradients, shadows, and atypical backgrounds
Implementation guidance
Focus should be managed through shared behavior rather than ad hoc fixes inside individual widgets. The application shell, modal layer, and navigation container should all cooperate around the same focus policy.
A strong default pattern is:
- define a logical tab order for each shell
- provide a visible focus ring on interactive elements
- restore focus after dismissing overlays
- avoid auto-focus in situations that interrupt the user without clear context
Example: focus restoration after a modal
#![allow(unused)]
fn main() {
use bevy::prelude::*;
use beverly::prelude::*;
fn open_dialog(mut commands: Commands, mut focus: FocusState) {
let trigger = focus.current();
commands.spawn(BeverlyModal::new("Delete item")
.body("This action cannot be undone.")
.confirm_label("Delete")
.cancel_label("Cancel")
.on_close(move |world| {
if let Some(element) = trigger {
world.focus(element);
}
}));
}
}
This pattern keeps the user anchored in their task: when the dialog ends, focus returns to the control that opened it instead of jumping unpredictably elsewhere.
Focus is not just a visual effect; it is the navigation system for keyboard users.
Reduced Motion
Reduced-motion preferences exist to protect users from distraction, vestibular discomfort, and motion overload. Beverly should treat motion as a design signal, not as a required delivery mechanism for understanding.
Core principle
When motion is reduced, the interface should stay clear and usable. The meaning of state changes, focus movement, and responsive behavior must remain intact even if animations are shortened or removed.
What should calm down
- decorative hover and press effects
- large panel reveal transitions
- list insertion or removal animation
- background effects that do not carry meaning
- repeated micro-animations on controls and cards
What should stay clear
- focus movement
- status updates
- loading and progress states
- error and warning communication
- app state transitions that are essential to context
Recommended implementation
Prefer a shared preference model instead of component-by-component exceptions. Motion policies should be driven centrally so the app behaves consistently across panels, cards, and overlays.
If reduced motion is enabled:
- shorten or remove non-essential transitions
- keep state changes immediate and understandable
- preserve readability and focus flow
- avoid hiding important information behind animation timing
Rule of thumb
If motion is removed and the interface still communicates clearly, the design is working. Reduced motion should reduce distraction without reducing understanding.
Example: motion-aware state transitions
#![allow(unused)]
fn main() {
use bevy::prelude::*;
use beverly::prelude::*;
fn spawn_status(mut commands: Commands) {
let prefers_reduced_motion = false;
commands.spawn((
NodeBundle::default(),
BeverlyAlert::new("Sync complete")
.variant(AlertVariant::Success)
.with_transition(if prefers_reduced_motion {
TransitionPolicy::Instant
} else {
TransitionPolicy::Fade
}),
));
}
}
This pattern preserves clarity while reducing unnecessary motion when the user has requested a calmer interaction model.
High Contrast
High-contrast modes improve legibility for users with low vision, glare sensitivity, or strong visual discomfort. Beverly should support this by ensuring that UI clarity does not depend on subtle color differences or low-contrast surfaces.
Design expectations
A high-contrast interface should still communicate:
- state and hierarchy
- distinction between primary and secondary actions
- boundaries between cards, panels, and controls
- focus visibility and error or warning states
Key rules
- Avoid relying on color alone to signal meaning.
- Keep text contrast strong enough for readability.
- Use borders, outlines, fill changes, or labels to reinforce differences.
- Maintain visible focus on all interactive elements.
- Treat shadows and glass effects as supplements, not the primary source of separation.
Beverly guidance
Theme tokens should support strong foreground/background contrast across surfaces. Glass, translucent, and layered treatments are useful, but they should never weaken the legibility of typography or the clarity of interactive states.
The app shell should define a robust visual hierarchy for:
- primary text
- secondary text
- interactive states
- alert and caution surfaces
- disabled content
Quality check
A good high-contrast mode should feel deliberate, not harsh. The interface should remain calm and legible while preserving the product’s brand and information hierarchy.
Screen Readers
Screen-reader support depends on semantics, not decoration. A polished interface is not accessible if a screen reader cannot understand the structure, names, or state of the controls.
Core requirements
Beverly projects its semantic tree through AccessKit. Bound validation errors create assertive error regions and invalid/error-description relationships. Notifications have live-region semantics, and applications can request an announcement explicitly:
#![allow(unused)]
fn main() {
use beverly::primitives::semantic::{AccessibilityAnnouncement, AnnouncementPriority};
world.write_message(AccessibilityAnnouncement {
message: "Changes saved".to_owned(),
priority: AnnouncementPriority::Polite,
});
}
Install SemanticPlugin (included by BeverlyPlugin) before sending messages.
Each nonempty message gets its own accessible region, so repeated identical
messages are not silently discarded. Regions remain in the tree for one second.
Use assertive announcements for urgent errors and polite announcements for
ordinary status changes. This adapter does not establish that a specific
screen reader announced a message; verify that on the supported platform.
The application should provide:
- clear text labels for controls and actions
- a meaningful reading order for panels and cards
- obvious state cues for selected, disabled, expanded, or alert surfaces
- consistent names for repeated controls, especially navigation items and form fields
Semantic structure
A Beverly interface should favor meaningful hierarchy over visual-only grouping. The app shell, cards, nav items, forms, and dialogs should all expose clear structure so screen-reader users can navigate with confidence.
When a control has a dynamic state, that state should be announced or discoverable. For example:
- selected or active tabs
- expanded or collapsed panels
- disabled controls
- validation states and error summaries
- busy or loading content
Naming and labeling
Labels should be concise, specific, and consistent. Generic names like “Button” or “Open” are not enough when several actions appear in the same interface. A user should be able to understand the action without seeing the visual layout.
This matters for:
- action buttons in toolbars
- form inputs and filters
- modal close or confirmation controls
- status surfaces and notifications
Example: labeled form controls with state
#![allow(unused)]
fn main() {
use bevy::prelude::*;
use beverly::prelude::*;
fn build_form(mut commands: Commands) {
commands.spawn((
NodeBundle::default(),
BeverlyStack::vertical(),
)).with_children(|parent| {
parent.spawn(BeverlyInput::new("Email address")
.placeholder("[email protected]")
.required(true));
parent.spawn(BeverlyButton::secondary("Send invite"));
});
}
}
The text label gives the field a clear accessible name, and the button label provides a precise meaning for assistive technologies.
Accessibility first, polish second
Glass surfaces, gradients, and motion effects can all be beautiful, but they should never obscure meaning. The semantic layer is what makes the interface understandable to every user, regardless of input method or assistive technology.
Performance Overview
Performance is an architectural requirement for Bevy-based user interfaces. In a UI framework, performance is not just about frames; it is about how much state churn, layout work, and asset cost the system creates while users work with the interface.
Design principles
- keep updates local and predictable
- limit unnecessary re-render and re-layout work
- avoid expensive pattern growth in large lists or dense tables
- preserve a stable layout while data changes underneath it
- keep rendering and interaction cost proportional to the actual user task
Example: constrained list workload
#![allow(unused)]
fn main() {
use bevy::prelude::*;
use beverly::prelude::*;
fn build_data_view(mut commands: Commands) {
commands.spawn((
NodeBundle::default(),
BeverlyList::new(),
)).with_children(|list| {
for i in 0..50 {
list.spawn(BeverlyListItem::new(format!("Item {i}")));
}
});
}
}
This keeps the content model explicit and understandable. For larger workloads, virtualization or paginated patterns are better than rendering every item with equal visual weight.
Guidance
Performance decisions should be visible in the architecture. A user interface that feels fast should also scale well when the data model grows, the screen becomes denser, or motion and effects become more complex.
ECS
ECS composition allows systems and data to stay efficient and composable. In a Bevy-based UI stack like Beverly, this is not just a performance optimization; it is the organizing principle for how the application model is structured.
What ECS means
ECS stands for Entity, Component, and System. Each term describes a different role in the architecture:
- Entity: the identity of a thing in the world or interface
- Component: the data attached to that thing
- System: the logic that processes entities with matching data
This separates identity, state, and behavior. A button, a card, a table row, a modal, or a form field can all be represented as entities without forcing them into a rigid inheritance hierarchy.
Entities are identity, not classes
An entity is usually just a unique id. It does not carry much behavior by itself. It is a placeholder for “this thing exists in the app.” In practice, a UI element may be a button entity, a panel entity, or an input entity, but those are not defined by one large object with every possible method attached.
This is useful because many UI primitives share common concepts: layout, focus, semantics, visibility, theme tokens, or interaction state. Instead of building a monolithic widget type tree, Beverly can represent a widget as an entity with the relevant components for its current purpose.
Components hold state
A component is plain data. It describes the facts about an entity.
Examples include:
- transform or layout info
- interaction state such as pressed, hovered, or disabled
- semantic metadata and accessibility attributes
- theme values like color, spacing, radius, or motion
- visibility and stacking order
- data bindings or selection state
A component should be small, focused, and easy to reason about. It answers the question: “What does this entity currently know?” It does not answer the question: “What should happen next?” That belongs to systems.
Systems implement behavior
A system is a function that runs over entities matching a specific set of components. A system might say:
- update layout for every entity with both Transform and Layout
- apply hover states for every entity with ButtonState and PointerInteraction
- compute accessibility relationships for focusable elements
- render visible nodes after the layout pass has completed
This creates a clear separation of responsibilities. Components define state, and systems define behavior. Instead of scattering logic across many object methods, the architecture collects related logic in one place and applies it to all matching entities.
Why this is a good fit for UI work
ECS gives Beverly a structure that is both efficient and composable.
Efficient
Data is processed in a way that keeps work local and predictable. Systems can iterate over only the entities they need, instead of walking a deeply nested object graph or performing broad state checks across unrelated widgets.
This matters in real interfaces where layout, interaction, and rendering all happen repeatedly and often under time constraints.
Composable
An entity can easily take on new capabilities by adding components. A widget can become focusable, semantically labeled, and theme-aware without requiring a new class hierarchy. This keeps reusable UI primitives flexible while preserving a strong architectural model.
Clear boundaries
A layout system does not need to know about how a button renders; a rendering system does not need to know about how data is stored; a theme system only concerns itself with token resolution and visual styling. This reduces accidental coupling and helps UI code stay understandable as the application grows.
Example: a button
A simple button could be represented as an entity with components like:
- Layout
- Transform
- ButtonState
- ThemeTokens
- AccessibilityLabel
- Visibility
Then several systems operate on it:
- LayoutSystem positions and sizes the button.
- InteractionSystem updates pressed, hovered, and disabled states.
- ThemeSystem applies colors, borders, and motion style.
- RenderSystem draws the button to the screen.
- AccessibilitySystem ensures the button exposes the proper semantics to assistive technology.
The same entity can participate in all of these systems without becoming a huge, brittle object.
ECS and Beverly’s architecture
This is why Beverly is described as ECS-first. The framework is built around the idea that the UI is not just an object tree; it is a composition of entities, stateful components, and systems that operate over that state.
The application layer remains focused on domain logic and workflows, while the UI layer provides reusable components and rules for layout, semantics, theming, and rendering. That keeps the architecture aligned with the way Bevy and ECS naturally think about scenes and systems.
The core takeaway
ECS is valuable because it makes the app easier to scale without turning every screen into a handcrafted set of special-case widgets. It keeps data local, behavior explicit, and composition flexible, which is exactly what a modern UI framework needs when the app grows beyond a few simple screens.
In short: entities are things, components are their data, and systems are the rules that act on that data. That model is the foundation of a performant, composable UI architecture.
Rendering
Rendering performance depends on practical batching, culling, and asset discipline. In Beverly, rendering is not a separate afterthought bolted onto the UI; it is part of the same ECS-driven architecture that handles layout, semantics, and interaction. The system keeps visual work organized so that a widget can render cleanly without becoming expensive or hard to reason about.
What rendering means in ECS
Rendering is the stage where the app turns layout state and visual data into pixels on screen. In a Bevy-based UI stack, that usually means the renderer works from entities that already have the right components: transform, layout, paint, material, visibility, and style tokens.
A typical flow looks like this:
- the layout system determines the position and size of each UI element
- the style system resolves visual tokens such as color, radius, and spacing
- the material system selects the correct surface treatment
- the render system draws the final visual output
This keeps rendering as a data-driven step rather than a hidden, imperative side effect inside each widget.
Material-first surfaces
Beverly treats surfaces as first-class UI objects. A surface is not just “some background with a color.” It is a structured visual layer that may include:
- fill color or gradients
- borders and corner radii
- shadows and highlights
- blur or translucency
- focus rings and interaction overlays
- shader-backed material effects
This is the same philosophy described in the rendering overview: UI surfaces carry semantic paint and border information, and effects are layered intentionally rather than injected ad hoc.
Because these aspects are material-aware, the same component model can support a plain panel, a glass card, a raised interaction surface, or a branded onboarding tile without inventing a completely different rendering path for each case.
The render pipeline
A practical rendering pipeline in Beverly-style architecture usually moves from coarse to fine:
- determine which entities are visible
- compute their layout and bounds
- resolve theme and surface parameters
- gather the set of paint operations needed
- apply effects such as shadows, gradients, or blur
- draw the final composite surface
This ordering matters. It prevents the render system from doing expensive work for elements that are not visible or are outside the current viewport. It also makes the visual stack easier to debug because each layer is explicit.
Why batching and culling matter
Rendering performance is not only about “how pretty is the shader.” It is also about how many draw calls and paint operations the app creates.
Two practical concerns are especially important:
Batching
When many similar elements share the same material or surface settings, they can often be drawn together in a single pass. This reduces CPU overhead and keeps the GPU busy with fewer state changes.
Culling
Visible elements should be drawn; hidden or clipped elements should not. Many UI systems waste work by rendering all surfaces in a scene even when only a fraction is on screen. In a dense dashboard or list-heavy interface, this can become a real bottleneck.
In ECS terms, culling is often implemented by checking the entities that match a renderable set and filtering them based on visibility and viewport bounds before the final draw step.
Materials and effects
Beverly’s approach is intentionally selective with advanced effects. A glass surface, blur layer, or gradient is valuable when it contributes clarity or visual hierarchy; it becomes expensive and distracting when applied everywhere.
This leads to a disciplined effect stack:
- base fill
- gradient or border layer
- shadow and surface depth
- interaction overlay
- blur or translucency only when needed
- accessibility and contrast safeguards
That progression is important because it preserves readability and avoids overusing complex shaders on simple UI surfaces.
Interaction and rendering are connected
Rendering is not independent of interaction. A button may have a different visual treatment when hovered, pressed, or focused. The same entity can carry interaction state and render styling, and the render system can react to that data without creating custom logic for every widget instance.
For example:
- hover state may change shadow and fill color
- focus state may add a ring or outline
- disabled state may reduce contrast and suppress motion
- pressed state may slightly inset the surface to create tactile feedback
This is why the rendering layer should be modeled as part of the same UI state machine as the rest of the system, not as a disconnected drawing step.
Accessibility and rendering
A performant UI is not just a fast UI; it is a readable and usable one. Rendering must respect reduced-motion, contrast requirements, and semantic clarity.
For example:
- avoid relying only on color to communicate state
- keep focus indicators visible and distinct
- reduce translucency in high-contrast modes
- avoid heavy motion and shimmer effects for users who prefer reduced motion
The render system can respond to policy data and accessibility preferences without separate code paths for every surface.
The core idea
Rendering in Beverly is best understood as a layered, data-driven process that sits inside the ECS architecture. Entities hold the state, materials define the paint model, and systems determine how the final visual output should be composed.
The result is a rendering model that is:
- composable
- visually expressive
- guided by material semantics
- efficient enough for dense interfaces
- respectful of accessibility and policy constraints
In short, rendering is not an isolated layer of drawing code; it is the visual expression of the same ECS model that drives layout, interaction, and state.
Virtualization
Virtualization limits work for large data surfaces. In a UI framework like Beverly, the main idea is simple: do not create, measure, and render every row in a giant list if the user can only see a small portion of it at any given moment. Virtualization keeps the interface responsive by rendering only the items that are effectively visible, while still preserving the illusion of a complete dataset.
Why virtualization is necessary
Large collections are common in modern apps: logs, chat threads, search results, transaction tables, file explorers, dashboards, and data feeds. A naive implementation may create and paint thousands or even tens of thousands of visible rows, even though the user only sees a small window at a time.
That creates several problems:
- expensive layout work for off-screen items
- unnecessary DOM or entity creation
- slower input handling and scrolling
- excess memory use and worse frame pacing
- a UI that feels sluggish even when the data itself is not large
Virtualization is the standard solution: keep the full dataset in memory, but only create and update the visible slice.
The core idea
Instead of rendering every item, the system calculates a visible window:
- the viewport size
- the scroll offset
- the estimated height of each item
- the number of items in the dataset
Using that information, it determines which items are currently visible or near-visible, then only mounts, updates, and renders those rows.
The rest of the list is treated as a virtualized “space” that exists logically, but not physically in the UI tree. This keeps the data model intact without paying the full layout cost for everything at once.
Virtualization in ECS terms
This fits well with ECS because the UI is defined as a set of entities with measurable state, not as one large object tree for every single item.
A virtualized list can be thought of as a narrow rendering window over a large collection. The actual list data remains in a data model, while the visible entities represent only the subset currently needed for display. Systems can update:
- scroll position
- visible item range
- row measurements
- item transforms and layout
- selection, hover, and keyboard focus state
When the user scrolls, the system changes the visible range and reuses existing row entities rather than recreating the entire list from scratch.
Reuse instead of rebuild
A major benefit of virtualization is that it reuses rendered rows. When the user scrolls a list, the system may keep the same entities and simply move their content to different data slots.
This pattern is much more efficient than rebuilding thousands of widgets each frame. It reduces churn and helps maintain a stable UI during interaction.
This is especially important for lists that need to remain interactive: selecting a row, keyboard traversal, hover feedback, and focus management should continue to work smoothly even when the visible set is small.
Why this matters for performance
Virtualization is not only about reducing rendering cost. It also reduces the cost of:
- text measurement and layout calculations
- CPU work spent updating large numbers of components
- memory pressure from large widget trees
- scrolling jank in dense interfaces
For large products, these effects compound quickly. A list that looks trivial at 50 rows may become unusable at 10,000 rows if the content is rendered indiscriminately.
A practical model
A typical virtualized list system operates in stages:
- determine container height and scroll offset
- estimate the item height or measure a consistent row size
- compute the visible start and end indexes
- create or recycle only those row entities
- update their content and layout based on the current scroll position
- skip rendering and measuring off-screen rows
This keeps the visible UI bounded to the user’s actual viewport while preserving a coherent dataset behind it.
Virtualization versus pagination
Virtualization and pagination solve related but distinct problems.
- Pagination loads a fixed page of results at a time.
- Virtualization keeps a continuous feeling of a long list while only drawing the active slice.
Pagination is often better for large server-backed datasets where a full collection is not practical to fetch at once. Virtualization is usually better when the user is scrolling through a long local dataset and expects smooth motion.
Both can be used together in a product strategy, but because Bevy and Beverly are oriented around composable UI state, virtualization often fits elegantly into the ECS model for dense lists and tables.
Important trade-offs
Virtualization is powerful, but it requires careful design:
- row heights must be predictable or measurable
- scroll anchoring must remain stable when content changes
- keyboard navigation should still map to logical item indexes
- selection and focus state must survive window changes
- empty or sparse regions need clear handling
If these details are ignored, the UI can feel jumpy or inconsistent even though it is technically faster.
The key takeaway
Virtualization is the technique of keeping the logical dataset large while only rendering the active portion. In an ECS-first UI system, this means maintaining a clear data model and a narrow set of visible entities that are updated as the user scrolls.
This is one of the clearest examples of performance work being architecture-aware rather than an optimization hack: it preserves usability, keeps the interface smooth, and reduces system cost without sacrificing a rich data model.
In short, virtualization keeps the app honest. It acknowledges that users only interact with a small window of a large dataset, and it designs the UI around that reality.
State Updates
State updates should be localized and predictable to avoid unnecessary churn. In a UI framework, the cost of a change is not just the value that changed; it is also the amount of dependent work that the change triggers. If one small interaction causes a large portion of the interface to recompute, the system begins to feel heavy even when the actual data is small.
Why state churn matters
Every user interaction modifies state. A click, a filter change, a selection update, or a scroll event all cause the app to react. The problem is not that state changes occur; the problem is when they spread across the whole UI architecture without regard to locality.
A poorly designed state flow often results in:
- global recomputation of unrelated widgets
- repeated layout passes for unchanged data
- cascading updates across many components
- unnecessary re-renders in large screens
- unstable frame pacing during normal interaction
These issues are often hidden in small apps but become obvious as the product grows.
Localized updates
The ideal state model is one where each update affects only the data and UI that truly depend on it.
For example:
- changing a filter should update the filtered dataset, not every row in the entire screen
- toggling a selected row should update selection state, not trigger unrelated layout work
- updating a modal field should only affect that modal, not the rest of the app shell
Localizing updates makes the system easier to reason about and also improves performance because fewer systems are triggered by each change.
Predictable change flow
A good state update should be easy to follow. The data flow should be clear:
- user action occurs
- relevant state is mutated
- dependent systems react
- only affected UI elements re-layout or repaint
This keeps the architecture stable and helps prevent surprising side effects. In ECS terms, the system operates on clearly defined component sets. If a button hover changes, only components relevant to interaction and rendering need to update. If a table dataset changes, only the list or table pipeline needs to respond.
ECS and state locality
ECS naturally supports localized updates because systems are organized by responsibility.
For example:
- a selection system reacts to Selection and Focusable components
- a layout system reacts to Layout and Transform components
- a theme system reacts to ThemeTokens and SurfaceState
- a rendering system reacts to visible and paintable components
This segmentation reduces cross-talk. Instead of one giant state object causing broad updates across the entire screen, the architecture allows each system to react only to the subset of entities it cares about.
The danger of broad state
When state is stored too centrally or mutated too broadly, small actions can trigger large ripple effects. A screen with many connected widgets may appear to work fine until every change starts touching shared global state.
That creates a pattern of churn:
- input updates a root state
- the root state triggers many recalculations
- many components are rebuilt
- layouts and rendering run again
- the app loses responsiveness
This is especially noticeable in dense dashboards, data-heavy tables, and multi-panel interfaces.
Better practice: narrow state ownership
The more a piece of state belongs to a specific feature, the easier it is to update precisely.
Examples:
- modal state belongs to that modal
- table selection belongs to the table view
- search query belongs to the search panel
- navigation state belongs to the app shell
When state ownership is narrow, updates remain predictable and easier to test. A user action in one component is less likely to accidentally trigger unrelated logic elsewhere in the window.
Derived state and computed values
Some state is best derived instead of stored eagerly. Derived values are a useful strategy for controlling churn.
For example:
- filtered item lists can be computed from raw data rather than stored separately and mutated manually
- layout metrics can be recalculated from current bounds instead of duplicated across many widgets
- theme tokens can be resolved from the active theme rather than spread as ad hoc per-element overrides
This reduces the number of moving parts and lowers the chance that multiple systems will fight over the same data.
The performance goal
The performance goal is not “never update state”; it is “update exactly the state that needs to change, and do it in a controlled way.”
Good state update design keeps the UI stable by ensuring that:
- updates are local
- dependent systems are clear
- cascades are minimal
- large screens remain responsive under repeated interaction
This is one of the most important practical benefits of an ECS architecture: it makes state flow easier to model and therefore easier to optimize.
The key takeaway
State updates should be treated as a system concern, not as an uncontrolled wave of mutations. In a well-structured UI framework, each state change is localized, predictable, and limited to the entities and systems that actually depend on it.
That is how Beverly keeps its UI responsive: state updates are organized around the same component and system boundaries that structure the app itself.
Profiling
Profiling reveals where UI behavior or asset costs are accumulating. In a system like Beverly, profiling is not a last-minute optimization step; it is part of how the architecture stays healthy as the interface grows. The goal is to identify which systems, assets, or interaction patterns are creating the most work and then fix the actual bottleneck rather than guessing.
Why profiling matters
A UI may feel slow for many different reasons: layout churn, expensive shaders, a large list, redundant state updates, or a single overused asset. Without profiling, these issues are difficult to distinguish. A system may look fine in isolated code but still become a bottleneck when many widgets are active at once.
Profiling matters because it turns vague complaints like “the app feels laggy” into concrete questions such as:
- which system is consuming the most frame time?
- is layout or rendering dominating the cost?
- are unnecessary recomputations happening after each input event?
- are a few expensive materials or assets creating the slowdown?
This leads to better decisions and more durable performance work.
Profiling in an ECS architecture
ECS makes profiling more actionable because the work is naturally segmented by system. Each system can be measured independently, which makes it easier to see whether the bottleneck is in:
- layout
- interaction updates
- theme resolution
- data filtering
- garbage or allocations
- rendering and materials
- accessibility evaluation
Instead of asking “is the app slow?” the real question becomes: “which system is doing too much work?” That is a much more productive problem to solve.
Typical profiling signals
Common indicators of performance problems include:
- a single system consuming an unusually large share of frame time
- frequent layout work on large screens
- repeated state updates triggered by small input events
- high asset or material cost during transitions or scrolls
- visible frame drops while the user is interacting with a dense list or dashboard
These signals often reveal systemic issues, not isolated code bugs. A single expensive pattern may be repeated across many entities, which multiplies the cost across the whole UI.
Measuring layout and update cost
In a layout-heavy UI, profiling should pay attention to the cost of:
- measuring text and content
- resolving nested layout constraints
- recalculating bounds after updates
- reflowing large sections after state changes
If a small change in one part of the app causes a large section to re-layout, that is a design signal. It means the system is over-coupled or too eager to recompute. Profiling helps reveal whether the update graph is too broad.
Measuring render cost
Render cost is often more obvious than layout cost because it shows up as frame stalls, dropped frames, or poor responsiveness during motion. Profiling a render-heavy interface should check:
- material complexity
- shader cost
- number of draw calls
- number of visible elements being painted
- whether off-screen or hidden surfaces are still being processed
This is where batching, culling, and material discipline become measurable facts rather than abstract rules.
Measuring asset cost
Assets are a common source of hidden overhead in UI frameworks. Large images, expensive textures, duplicated materials, or excessive shader parameters can create a large cost even when the logic itself is fine.
Profiling should therefore look at:
- which assets are loaded most often
- whether textures are too large for their actual use
- whether effect-heavy surfaces are applied broadly instead of selectively
- whether cached materials or bindings are being recreated unnecessarily
Good profiling keeps the visual design honest by tying aesthetic choices to runtime cost.
Profiling user-visible behavior
Not all performance problems show up in raw timing metrics. Sometimes the issue is interaction latency, delayed scroll response, or a button that feels “sticky” after a state change. That means profiling should also consider user experience signals such as:
- scroll smoothness
- hover and tap responsiveness
- keyboard navigation latency
- animation fluidity
- transition clarity during dense data updates
A UI can be technically correct and still feel slow if state churn or repeated layout updates make interaction less responsive.
A disciplined profiling workflow
A practical profiling workflow in a Bevy-style architecture is:
- reproduce the issue under a realistic workload
- identify the affected system or area of the UI
- isolate the operation or interaction causing the spike
- measure the hot path: layout, interaction, render, or asset cost
- fix the root cause
- re-measure and compare before and after
The key is to profile with intent. A single number is not enough; the pattern matters. Small slowdowns may be okay in one area, but a hot system that repeatedly fires on every frame or every input is a serious design problem.
Profiling as part of design
The best performance work is not reactive; it is embedded into the design of the system. Beverly’s architecture encourages this by keeping concerns separated:
- UI composition stays distinct from app workflows
- layout is isolated from rendering
- interaction state stays separate from material state
- accessibility and policy constraints are built into the stack
Because those boundaries are explicit, profiling becomes more meaningful. It shows whether a bottleneck is in the wrong layer or caused by coupling across layers.
The key takeaway
Profiling is how a system learns where its real costs are. In a layered, ECS-first framework, profiling is most valuable when it answers a system-level question: which part of the app is doing the expensive work, and why?
That makes profiling an architectural tool, not just a debugging habit. It helps Beverly keep the UI fast, understandable, and resilient as the application grows.
Examples
The interactive examples below are built from the runnable Rust sources in
examples/. The examples use the public APIs directly; they are not separate
mockups or proposed component interfaces.
Components
Open the demo in a separate tab.
Showcase
Open the demo in a separate tab.
For a minimal native example, see Hello World. For a message-driven button example, see Button Click Event.
15.1 Hello World
Live Demo
Open the demo in a separate tab.
This is the smallest useful Beverly app: a root Ui composition with a centered,
themed text node.
use bevy::prelude::*;
use beverly::prelude::*;
fn main() {
App::new().ui(my_ui()).run();
}
fn my_ui() -> Ui {
ui()
.width(percent(100))
.height(percent(100))
.center()
.theme(light_theme())
.children([text("Hello, World!")])
}
This same example is available in the repository at examples/hello_world.rs.
15.2 Button Click Event
This example extends 15.1 by adding a button, firing an app message when it is pressed, and listening for that message to log the click.
use bevy::prelude::*;
use beverly::prelude::*;
#[derive(Message)]
struct HelloButtonClicked;
fn main() {
App::new()
.add_plugins(DefaultPlugins)
.add_plugins(BeverlyPlugin)
.insert_resource(ThemeResource {
current: light_theme(),
})
.add_message::<HelloButtonClicked>()
.add_systems(Startup, setup)
.add_systems(Update, log_click_event)
.run();
}
fn setup(mut commands: Commands) {
commands
.spawn(Node {
width: percent(100),
height: percent(100),
justify_content: JustifyContent::Center,
align_items: AlignItems::Center,
..default()
})
.with_children(|parent| {
parent.spawn(button().text("Click me").on("click", emit_click));
});
}
fn emit_click(commands: &mut Commands, _button: Entity) {
commands.write_message(HelloButtonClicked);
}
fn log_click_event(mut reader: MessageReader<HelloButtonClicked>) {
for _ in reader.read() {
info!("Hello button clicked");
}
}
When you press the button, this app writes Hello button clicked to the console.
Contributing
Contributions to Beverly should focus on clear API design, performance, and accessibility.
Principles
- Prefer reusable primitives over app-specific widgets
- Keep interactions accessible by default
- Benchmark changes that affect rendering or layout
Workflow
- Start with a focused issue or proposal
- Add tests for behavior changes
- Update docs when API changes
- Run cargo fmt and cargo test
Roadmap
This roadmap captures the near-term direction of the project.
Current priorities
- Core component library
- Theme system
- Layout primitives
- Rendering and shaders
- AI interaction patterns
Later milestones
- Real-world app examples
- Performance benchmarking
- Expanded accessibility tooling
Changelog
Unreleased
- Added sequential and parallel controller composition with
controller!. - Added named component sections, two-way control binding, and typed Select binding.
- Added GET, POST, PUT, and PATCH form submission requests with URL-encoded and JSON bodies.
- Added fluent static and interaction-state styling APIs.
- Added local/external raster and SVG image loading with status reporting.
- Expanded keyboard, validation, and accessibility state handling across controls.
- Added documentation structure for foundations, components, and system guides
- Documented the core architecture and developer workflow
- Established the initial roadmap and contribution guidance