Introduction
Blinc is a GPU-accelerated, reactive UI framework for Rust. It provides a declarative, component-based approach to building high-performance user interfaces with smooth animations and modern visual effects.
Why Blinc?
-
GPU-Accelerated Rendering - All rendering is done on the GPU via wgpu, enabling smooth 60fps animations and complex visual effects like glass materials and shadows.
-
Declarative UI - Build interfaces using a fluent, composable API inspired by SwiftUI and modern web frameworks. No manual DOM manipulation.
-
Reactive State - Automatic UI updates when state changes, with fine-grained reactivity for optimal performance.
-
Spring Physics - Natural, physics-based animations using spring dynamics instead of fixed durations.
-
Cross-Platform - Runs on macOS, Windows, Linux, and Android (iOS coming soon).
Key Features
Flexbox Layout
All layout is powered by Taffy, a high-performance flexbox implementation. Use familiar CSS-like properties:
#![allow(unused)]
fn main() {
div()
.flex_col()
.gap(16.0)
.p(24.0)
.child(text("Hello"))
.child(text("World"))
}
Material Effects
Built-in support for glass, metallic, and other material effects:
#![allow(unused)]
fn main() {
div()
.glass()
.rounded(16.0)
.p(24.0)
.child(text("Frosted Glass"))
}
Type-Safe Animations
The BlincComponent derive macro generates type-safe animation hooks:
#![allow(unused)]
fn main() {
#[derive(BlincComponent)]
struct MyCard {
#[animation]
scale: f32,
#[animation]
opacity: f32,
}
// Usage
let scale = MyCard::use_scale(ctx, 1.0, SpringConfig::snappy());
let opacity = MyCard::use_opacity(ctx, 0.0, SpringConfig::gentle());
}
Event Handling
Intuitive event handling with closures:
#![allow(unused)]
fn main() {
div()
.on_click(|_| println!("Clicked!"))
.on_hover_enter(|_| println!("Hovered"))
}
Architecture Overview
┌─────────────────────────────────────────────────────┐
│ Your Application │
├─────────────────────────────────────────────────────┤
│ blinc_app │ WindowedApp, Context, State Hooks │
├──────────────┼──────────────────────────────────────┤
│ blinc_layout│ Elements, Flexbox, Event Routing │
├──────────────┼──────────────────────────────────────┤
│ blinc_animation │ Springs, Timelines, Motion │
├──────────────┼──────────────────────────────────────┤
│ blinc_gpu │ Render Pipeline, Materials │
├──────────────┼──────────────────────────────────────┤
│ wgpu │ GPU Abstraction Layer │
└─────────────────────────────────────────────────────┘
Quick Example
Here’s a minimal Blinc application:
use blinc_app::prelude::*;
use blinc_app::windowed::{WindowedApp, WindowedContext};
fn main() -> Result<()> {
WindowedApp::run(WindowConfig::default(), |ctx| {
div()
.w(ctx.width)
.h(ctx.height)
.bg(Color::rgba(0.1, 0.1, 0.15, 1.0))
.flex_center()
.child(
div()
.glass()
.rounded(16.0)
.p(32.0)
.child(text("Hello, Blinc!").size(24.0).color(Color::WHITE))
)
})
}
Next Steps
- Installation - Set up your development environment
- Your First App - Build a complete application step by step
- Elements & Layout - Learn about available UI elements
Installation
Prerequisites
Blinc requires:
- Rust 1.70+ (for stable async and other features)
- A GPU with Vulkan, Metal, or DX12 support
Adding Blinc to Your Project
Add blinc_app to your Cargo.toml:
[dependencies]
blinc_app = { version = "0.1", features = ["windowed"] }
The windowed feature enables desktop windowing support. For headless rendering (e.g., server-side), omit this feature.
Feature Flags
| Feature | Description |
|---|---|
windowed | Desktop window support via winit (default) |
android | Android platform support |
Verifying Installation
Create a simple test application:
// src/main.rs
use blinc_app::prelude::*;
use blinc_app::windowed::{WindowedApp, WindowedContext};
fn main() -> Result<()> {
WindowedApp::run(WindowConfig::default(), |ctx| {
div()
.w(ctx.width)
.h(ctx.height)
.bg(Color::rgba(0.1, 0.1, 0.15, 1.0))
.flex_center()
.child(text("Blinc is working!").size(32.0).color(Color::WHITE))
})
}
Run with:
cargo run
You should see a window with “Blinc is working!” displayed in the center.
Recommended Dev Dependencies
For a better development experience, add these to your Cargo.toml:
[dev-dependencies]
tracing-subscriber = "0.3"
Then initialize logging in your app:
fn main() -> Result<()> {
tracing_subscriber::fmt()
.with_max_level(tracing::Level::INFO)
.init();
WindowedApp::run(/* ... */)
}
Platform-Specific Notes
macOS
No additional setup required. Blinc uses Metal for GPU rendering.
Windows
Ensure you have up-to-date GPU drivers. Blinc uses DX12 by default, falling back to Vulkan.
Linux
Install Vulkan development libraries:
# Ubuntu/Debian
sudo apt install libvulkan-dev
# Fedora
sudo dnf install vulkan-devel
# Arch
sudo pacman -S vulkan-icd-loader
Android
See the Android platform guide for cross-compilation setup.
Your First App
Let’s build a simple counter application to learn Blinc fundamentals.
The Basic Structure
Every Blinc windowed application follows this pattern:
use blinc_app::prelude::*;
use blinc_app::windowed::{WindowedApp, WindowedContext};
fn main() -> Result<()> {
WindowedApp::run(WindowConfig::default(), |ctx| {
// Your UI goes here
build_ui(ctx)
})
}
fn build_ui(ctx: &WindowedContext) -> impl ElementBuilder {
div()
.w(ctx.width)
.h(ctx.height)
// ... children
}
The WindowedApp::run function:
- Creates a window with the given configuration
- Sets up the GPU renderer
- Calls your UI builder function when needed
- Handles events and animations automatically
Building a Counter
Let’s create a counter with increment and decrement buttons.
Step 1: Window Configuration
use blinc_app::prelude::*;
use blinc_app::windowed::{WindowedApp, WindowedContext};
fn main() -> Result<()> {
let config = WindowConfig {
title: "Counter App".to_string(),
width: 400,
height: 300,
resizable: true,
..Default::default()
};
WindowedApp::run(config, |ctx| build_ui(ctx))
}
Step 2: Creating State
Use use_state_keyed to create reactive state that persists across UI rebuilds:
#![allow(unused)]
fn main() {
fn build_ui(ctx: &WindowedContext) -> impl ElementBuilder {
// Create keyed state for the count - persists across rebuilds
let count = ctx.use_state_keyed("counter", || 0i32);
// State will be read inside stateful elements via .deps()
// ... rest of UI
}
}
Step 3: Building the Layout with Stateful Elements
The key insight in Blinc is that UI doesn’t rebuild on every state change. Instead, we use stateful(handle) with .deps() to react to state changes:
#![allow(unused)]
fn main() {
use blinc_layout::stateful::stateful;
fn build_ui(ctx: &WindowedContext) -> impl ElementBuilder {
let count = ctx.use_state_keyed("counter", || 0i32);
let container_handle = ctx.use_state(ButtonState::Idle);
div()
.w(ctx.width)
.h(ctx.height)
.bg(Color::rgba(0.08, 0.08, 0.12, 1.0))
.flex_col()
.justify_center()
.items_center()
.gap(24.0)
// Title
.child(
text("Counter")
.size(32.0)
.weight(FontWeight::Bold)
.color(Color::WHITE)
)
// Count display - uses stateful with deps to update when count changes
.child(count_display(ctx, count.clone()))
// Buttons row
.child(
div()
.flex_row()
.gap(16.0)
.child(counter_button(ctx, count.clone(), "-", -1))
.child(counter_button(ctx, count.clone(), "+", 1))
)
}
}
Step 4: Creating the Count Display
The count display needs to update when the count changes. We use stateful(handle) with .deps():
#![allow(unused)]
fn main() {
fn count_display(ctx: &WindowedContext, count: State<i32>) -> impl ElementBuilder {
let handle = ctx.use_state(ButtonState::Idle);
stateful(handle)
.deps(&[count.signal_id()])
.on_state(move |_state, container| {
let current = count.get();
container.merge(
div()
.child(
text(&format!("{}", current))
.size(64.0)
.weight(FontWeight::Bold)
.color(Color::rgba(0.4, 0.6, 1.0, 1.0))
)
);
})
}
}
Step 5: Creating Interactive Buttons
For interactive buttons with hover and press states, use stateful(handle):
#![allow(unused)]
fn main() {
fn counter_button(
ctx: &WindowedContext,
count: State<i32>,
label: &'static str,
delta: i32,
) -> impl ElementBuilder {
// Use use_state_for for reusable components with a unique key
let handle = ctx.use_state_for(label, ButtonState::Idle);
stateful(handle)
.w(60.0)
.h(60.0)
.rounded(12.0)
.flex_center()
.on_state(|state, div| {
// Apply different styles based on current state
let bg = match state {
ButtonState::Idle => Color::rgba(0.2, 0.2, 0.25, 1.0),
ButtonState::Hovered => Color::rgba(0.3, 0.3, 0.35, 1.0),
ButtonState::Pressed => Color::rgba(0.15, 0.15, 0.2, 1.0),
ButtonState::Disabled => Color::rgba(0.1, 0.1, 0.12, 0.5),
};
div.set_bg(bg);
})
.on_click(move |_| {
count.update(|v| v + delta);
})
.child(
text(label)
.size(28.0)
.weight(FontWeight::Bold)
.color(Color::WHITE)
)
}
}
Complete Example
Here’s the full counter application:
use blinc_app::prelude::*;
use blinc_app::windowed::{WindowedApp, WindowedContext};
use blinc_layout::stateful::stateful;
fn main() -> Result<()> {
tracing_subscriber::fmt()
.with_max_level(tracing::Level::INFO)
.init();
let config = WindowConfig {
title: "Counter App".to_string(),
width: 400,
height: 300,
resizable: true,
..Default::default()
};
WindowedApp::run(config, |ctx| build_ui(ctx))
}
fn build_ui(ctx: &WindowedContext) -> impl ElementBuilder {
let count = ctx.use_state_keyed("counter", || 0i32);
div()
.w(ctx.width)
.h(ctx.height)
.bg(Color::rgba(0.08, 0.08, 0.12, 1.0))
.flex_col()
.justify_center()
.items_center()
.gap(24.0)
.child(
text("Counter")
.size(32.0)
.weight(FontWeight::Bold)
.color(Color::WHITE)
)
.child(count_display(ctx, count.clone()))
.child(
div()
.flex_row()
.gap(16.0)
.child(counter_button(ctx, count.clone(), "-", -1))
.child(counter_button(ctx, count.clone(), "+", 1))
)
}
fn count_display(ctx: &WindowedContext, count: State<i32>) -> impl ElementBuilder {
let handle = ctx.use_state(ButtonState::Idle);
stateful(handle)
.deps(&[count.signal_id()])
.on_state(move |_state, container| {
let current = count.get();
container.merge(
div()
.child(
text(&format!("{}", current))
.size(64.0)
.weight(FontWeight::Bold)
.color(Color::rgba(0.4, 0.6, 1.0, 1.0))
)
);
})
}
fn counter_button(
ctx: &WindowedContext,
count: State<i32>,
label: &'static str,
delta: i32,
) -> impl ElementBuilder {
let handle = ctx.use_state_for(label, ButtonState::Idle);
stateful(handle)
.w(60.0)
.h(60.0)
.rounded(12.0)
.flex_center()
.on_state(|state, div| {
let bg = match state {
ButtonState::Idle => Color::rgba(0.2, 0.2, 0.25, 1.0),
ButtonState::Hovered => Color::rgba(0.3, 0.3, 0.35, 1.0),
ButtonState::Pressed => Color::rgba(0.15, 0.15, 0.2, 1.0),
ButtonState::Disabled => Color::rgba(0.1, 0.1, 0.12, 0.5),
};
div.set_bg(bg);
})
.on_click(move |_| {
count.update(|v| v + delta);
})
.child(
text(label)
.size(28.0)
.weight(FontWeight::Bold)
.color(Color::WHITE)
)
}
Tip: For more examples, explore the
crates/blinc_app/examples/directory which includeswindowed.rs,canvas_demo.rs,motion_demo.rs, and more.
Key Concepts Learned
- WindowedApp::run - Entry point for desktop applications
- WindowedContext - Provides window dimensions and state hooks
- use_state_keyed - Creates reactive state with a string key
- stateful(handle) - Creates elements that react to state changes
- deps() - Declares signal dependencies for reactive updates
- on_state - Callback that runs when state or dependencies change
- Fluent Builder API - Chain methods like
.w(),.h(),.child() - Flexbox Layout - Use
.flex_col(),.flex_center(),.gap()
Next Steps
- Learn about all available Elements & Layout
- Add Spring Animations to your counter
- Explore Styling & Materials for visual polish
Project Structure
Recommended Layout
For a typical Blinc application:
my-app/
├── Cargo.toml
├── src/
│ ├── main.rs # Application entry point
│ ├── app.rs # Main UI builder
│ ├── components/ # Reusable UI components
│ │ ├── mod.rs
│ │ ├── header.rs
│ │ ├── sidebar.rs
│ │ └── card.rs
│ ├── screens/ # Full-page views
│ │ ├── mod.rs
│ │ ├── home.rs
│ │ └── settings.rs
│ └── state/ # Application state
│ ├── mod.rs
│ └── app_state.rs
└── assets/ # Static assets
├── fonts/
├── images/
└── icons/
Entry Point Pattern
// src/main.rs
use blinc_app::prelude::*;
use blinc_app::windowed::{WindowedApp, WindowedContext};
mod app;
mod components;
mod screens;
mod state;
fn main() -> Result<()> {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::from_default_env()
.add_directive(tracing::Level::INFO.into()),
)
.init();
let config = WindowConfig {
title: "My App".to_string(),
width: 1200,
height: 800,
resizable: true,
..Default::default()
};
WindowedApp::run(config, |ctx| app::build(ctx))
}
Component Organization
Simple Component
#![allow(unused)]
fn main() {
// src/components/card.rs
use blinc_app::prelude::*;
pub fn card(title: &str) -> Div {
div()
.p(16.0)
.rounded(12.0)
.bg(Color::rgba(0.15, 0.15, 0.2, 1.0))
.flex_col()
.gap(8.0)
.child(
text(title)
.size(18.0)
.weight(FontWeight::SemiBold)
.color(Color::WHITE)
)
}
}
Component with Children
#![allow(unused)]
fn main() {
// src/components/card.rs
pub fn card_with_content<E: ElementBuilder>(title: &str, content: E) -> Div {
div()
.p(16.0)
.rounded(12.0)
.bg(Color::rgba(0.15, 0.15, 0.2, 1.0))
.flex_col()
.gap(8.0)
.child(
text(title)
.size(18.0)
.weight(FontWeight::SemiBold)
.color(Color::WHITE)
)
.child(content)
}
}
Stateful Component with BlincComponent
#![allow(unused)]
fn main() {
// src/components/animated_card.rs
use blinc_app::prelude::*;
use blinc_app::windowed::WindowedContext;
use blinc_animation::SpringConfig;
use std::sync::Arc;
#[derive(BlincComponent)]
pub struct AnimatedCard {
#[animation]
scale: f32,
#[animation]
opacity: f32,
}
pub fn animated_card(ctx: &WindowedContext, title: &str) -> Div {
let scale = AnimatedCard::use_scale(ctx, 1.0, SpringConfig::snappy());
let opacity = AnimatedCard::use_opacity(ctx, 1.0, SpringConfig::gentle());
let hover_scale = Arc::clone(&scale);
let leave_scale = Arc::clone(&scale);
div()
.p(16.0)
.rounded(12.0)
.bg(Color::rgba(0.15, 0.15, 0.2, 1.0))
.transform(Transform::scale(scale.lock().unwrap().get()))
.opacity(opacity.lock().unwrap().get())
.on_hover_enter(move |_| {
hover_scale.lock().unwrap().set_target(1.05);
})
.on_hover_leave(move |_| {
leave_scale.lock().unwrap().set_target(1.0);
})
.child(text(title).size(18.0).color(Color::WHITE))
}
}
Screen Organization
#![allow(unused)]
fn main() {
// src/screens/home.rs
use blinc_app::prelude::*;
use blinc_app::windowed::WindowedContext;
use crate::components::{header, card};
pub fn home_screen(ctx: &WindowedContext) -> impl ElementBuilder {
div()
.w(ctx.width)
.h(ctx.height)
.bg(Color::rgba(0.08, 0.08, 0.12, 1.0))
.flex_col()
.child(header::header(ctx))
.child(
div()
.flex_1()
.p(24.0)
.flex_col()
.gap(16.0)
.child(card("Welcome"))
.child(card("Getting Started"))
)
}
}
State Management Patterns
Global App State
#![allow(unused)]
fn main() {
// src/state/app_state.rs
use blinc_core::reactive::Signal;
use blinc_app::windowed::WindowedContext;
pub struct AppState {
pub user_name: Signal<String>,
pub theme: Signal<Theme>,
pub sidebar_open: Signal<bool>,
}
impl AppState {
pub fn new(ctx: &WindowedContext) -> Self {
Self {
user_name: ctx.use_signal(String::new()),
theme: ctx.use_signal(Theme::Dark),
sidebar_open: ctx.use_signal(true),
}
}
}
#[derive(Clone, Copy, PartialEq)]
pub enum Theme {
Light,
Dark,
}
}
Using App State
#![allow(unused)]
fn main() {
// src/app.rs
use blinc_app::prelude::*;
use blinc_app::windowed::WindowedContext;
use crate::state::AppState;
use crate::screens;
pub fn build(ctx: &WindowedContext) -> impl ElementBuilder {
let state = AppState::new(ctx);
div()
.w(ctx.width)
.h(ctx.height)
.flex_row()
.child(sidebar(ctx, &state))
.child(main_content(ctx, &state))
}
fn sidebar(ctx: &WindowedContext, state: &AppState) -> Div {
let is_open = ctx.get(state.sidebar_open).unwrap_or(true);
if is_open {
div().w(250.0).h_full().bg(Color::rgba(0.1, 0.1, 0.15, 1.0))
// ... sidebar content
} else {
div().w(0.0).h(0.0)
}
}
}
Module Re-exports
#![allow(unused)]
fn main() {
// src/components/mod.rs
mod card;
mod header;
mod sidebar;
mod animated_card;
pub use card::*;
pub use header::*;
pub use sidebar::*;
pub use animated_card::*;
}
#![allow(unused)]
fn main() {
// src/screens/mod.rs
mod home;
mod settings;
pub use home::*;
pub use settings::*;
}
Asset Loading
For images and other assets, use relative paths from your project root:
#![allow(unused)]
fn main() {
// Load an image
image("assets/images/logo.png")
.w(100.0)
.h(100.0)
.contain()
// Load an SVG icon
svg("assets/icons/menu.svg")
.w(24.0)
.h(24.0)
.tint(Color::WHITE)
}
Tips
- Keep components small - Each component should do one thing well
- Use BlincComponent - For any component with animations or complex state
- Separate concerns - UI building, state management, and business logic
- Use the prelude -
use blinc_app::prelude::*imports common items - Consistent naming - Use
_screensuffix for full-page views, no suffix for components
Mobile Development
Blinc supports building native mobile applications for both Android and iOS platforms. The same Rust UI code runs on mobile with platform-specific rendering backends (Vulkan for Android, Metal for iOS).
Cross-Platform Architecture
┌─────────────────────────────────────────────────────────────┐
│ Your Blinc App │
│ (Shared Rust UI code, state, animations) │
└─────────────────────────────┬───────────────────────────────┘
│
┌────────────────────┼────────────────────┐
│ │ │
┌────▼────┐ ┌─────▼─────┐ ┌────▼────┐
│ Desktop │ │ Android │ │ iOS │
│ (wgpu) │ │ (Vulkan) │ │ (Metal) │
└─────────┘ └───────────┘ └─────────┘
Key Features
- Shared UI Code: Write your UI once in Rust, deploy everywhere
- Native Performance: GPU-accelerated rendering via Vulkan/Metal
- Touch Support: Full multi-touch gesture handling
- Reactive State: Same reactive state system as desktop
- Animations: Spring physics and keyframe animations work seamlessly
Supported Platforms
| Platform | Backend | Min Version | Status |
|---|---|---|---|
| Android | Vulkan | API 24 (7.0) | Stable |
| iOS | Metal | iOS 15+ | Stable |
Project Structure
A typical Blinc mobile project looks like this:
my-app/
├── Cargo.toml # Rust dependencies
├── blinc.toml # Blinc project config
├── src/
│ └── main.rs # Shared UI code
├── platforms/
│ ├── android/ # Android-specific files
│ │ ├── app/
│ │ │ └── src/main/
│ │ │ ├── AndroidManifest.xml
│ │ │ └── kotlin/.../MainActivity.kt
│ │ └── build.gradle.kts
│ └── ios/ # iOS-specific files
│ ├── BlincApp/
│ │ ├── AppDelegate.swift
│ │ ├── BlincViewController.swift
│ │ └── Info.plist
│ └── BlincApp.xcodeproj/
└── build-android.sh # Build scripts
Quick Start
1. Create a new mobile project
blinc new my-app --template rust
cd my-app
2. Write your UI
#![allow(unused)]
fn main() {
use blinc_app::prelude::*;
fn app(ctx: &mut WindowedContext) -> impl ElementBuilder {
let count = ctx.use_state_keyed("count", || 0i32);
div()
.w(ctx.width)
.h(ctx.height)
.bg(Color::from_hex(0x1a1a2e))
.flex_col()
.items_center()
.justify_center()
.gap(20.0)
.child(counter_display(count.clone()))
.child(counter_button("+", count.clone(), 1))
}
fn counter_display(count: State<i32>) -> impl ElementBuilder {
// Stateful elements with deps update incrementally when dependencies change
stateful::<NoState>()
.deps([count.signal_id()])
.on_state(move |_ctx| {
text(format!("Count: {}", count.get()))
.size(48.0)
.color(Color::WHITE)
})
}
fn counter_button(label: &str, count: State<i32>, delta: i32) -> impl ElementBuilder {
let label = label.to_string();
stateful::<ButtonState>()
.on_state(move |ctx| {
let bg = match ctx.state() {
ButtonState::Idle => Color::from_hex(0x4a4a5a),
ButtonState::Hovered => Color::from_hex(0x5a5a6a),
ButtonState::Pressed => Color::from_hex(0x3a3a4a),
ButtonState::Disabled => Color::from_hex(0x2a2a2a),
};
div()
.w(80.0).h(50.0)
.rounded(8.0)
.bg(bg)
.items_center()
.justify_center()
.child(text(&label).size(24.0).color(Color::WHITE))
})
.on_click(move |_| count.set(count.get() + delta))
}
}
3. Build and run
# Android
blinc run android
# iOS
blinc run ios
Next Steps
- Android Development - Set up Android toolchain and build
- iOS Development - Set up iOS toolchain and build
- CLI Reference - Full CLI command reference
Android Development
This guide covers setting up your environment and building Blinc apps for Android.
Prerequisites
1. Android SDK & NDK
Install Android Studio or the standalone SDK:
# macOS (via Homebrew)
brew install --cask android-studio
# Or download from https://developer.android.com/studio
Set up environment variables:
export ANDROID_HOME=$HOME/Library/Android/sdk
export ANDROID_NDK_HOME=$ANDROID_HOME/ndk/26.1.10909125
export PATH=$PATH:$ANDROID_HOME/platform-tools
2. Rust Android Targets
rustup target add aarch64-linux-android
rustup target add armv7-linux-androideabi
rustup target add x86_64-linux-android
rustup target add i686-linux-android
3. cargo-ndk
cargo install cargo-ndk
Building
Debug Build
# Build for arm64 (most modern devices)
cargo ndk -t arm64-v8a build
# Build for multiple architectures
cargo ndk -t arm64-v8a -t armeabi-v7a build
Release Build
cargo ndk -t arm64-v8a build --release
Using Gradle
From the platforms/android directory:
./gradlew assembleDebug
The APK will be at app/build/outputs/apk/debug/app-debug.apk.
Project Configuration
Cargo.toml
[lib]
name = "my_app"
crate-type = ["cdylib", "staticlib"]
[target.'cfg(target_os = "android")'.dependencies]
blinc_app = { version = "0.1", features = ["android"] }
blinc_platform_android = "0.1"
android-activity = { version = "0.6", features = ["native-activity"] }
log = "0.4"
android_logger = "0.14"
AndroidManifest.xml
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-feature android:glEsVersion="0x00030000" android:required="true" />
<application
android:label="My App"
android:theme="@android:style/Theme.DeviceDefault.NoActionBar.Fullscreen"
android:hardwareAccelerated="true">
<activity
android:name=".MainActivity"
android:configChanges="orientation|screenSize|keyboardHidden"
android:exported="true">
<meta-data
android:name="android.app.lib_name"
android:value="my_app" />
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
Touch Event Handling
Android touch events are automatically routed to your UI. The touch phases map as follows:
| Android Action | Blinc Event |
|---|---|
| ACTION_DOWN | pointer_down |
| ACTION_MOVE | pointer_move |
| ACTION_UP | pointer_up + pointer_leave |
| ACTION_CANCEL | pointer_leave |
Debugging
View Logs
adb logcat | grep -E "(blinc|BlincApp)"
Common Issues
“Library not found”
Ensure the native library is built and copied to app/src/main/jniLibs/:
cargo ndk -t arm64-v8a build
cp target/aarch64-linux-android/debug/libmy_app.so \
platforms/android/app/src/main/jniLibs/arm64-v8a/
“Vulkan not supported”
Check device compatibility:
adb shell getprop ro.hardware.vulkan
Most devices with API 24+ support Vulkan, but some older devices may not.
Touch events not working
- Verify the render context is created successfully
- Check that
android.app.lib_namein manifest matches your library name - Look for errors in logcat
Performance Tips
- Use release builds for performance testing
- Enable LTO in Cargo.toml:
[profile.release] lto = "thin" opt-level = "z" - Test on real devices - emulators have different GPU characteristics
iOS Development
This guide covers setting up your environment and building Blinc apps for iOS.
Prerequisites
1. Xcode
Install Xcode 15+ from the Mac App Store or Apple Developer website.
# Verify installation
xcode-select -p
2. Rust iOS Targets
rustup target add aarch64-apple-ios # Device (arm64)
rustup target add aarch64-apple-ios-sim # Simulator (Apple Silicon)
rustup target add x86_64-apple-ios # Simulator (Intel)
Building
Build Script
Create a build script build-ios.sh:
#!/bin/bash
set -e
MODE=${1:-debug}
PROJECT_NAME="my_app"
if [ "$MODE" = "release" ]; then
CARGO_FLAGS="--release"
TARGET_DIR="release"
else
CARGO_FLAGS=""
TARGET_DIR="debug"
fi
# Build for device
cargo build --target aarch64-apple-ios $CARGO_FLAGS
# Build for simulator (Apple Silicon)
cargo build --target aarch64-apple-ios-sim $CARGO_FLAGS
# Copy to libs directory
mkdir -p platforms/ios/libs/device
mkdir -p platforms/ios/libs/simulator
cp target/aarch64-apple-ios/$TARGET_DIR/lib${PROJECT_NAME}.a \
platforms/ios/libs/device/
cp target/aarch64-apple-ios-sim/$TARGET_DIR/lib${PROJECT_NAME}.a \
platforms/ios/libs/simulator/
Building
# Debug build
./build-ios.sh
# Release build
./build-ios.sh release
Xcode
- Open
platforms/ios/BlincApp.xcodeproj - Select your target (device or simulator)
- Press Cmd+R to build and run
Project Configuration
Cargo.toml
[lib]
name = "my_app"
crate-type = ["cdylib", "staticlib"]
[target.'cfg(target_os = "ios")'.dependencies]
blinc_app = { version = "0.1", features = ["ios"] }
blinc_platform_ios = "0.1"
Xcode Build Settings
In your Xcode project:
-
Link the static library:
- Build Phases → Link Binary With Libraries
- Add
libmy_app.afromlibs/device/orlibs/simulator/
-
Set the bridging header:
- Build Settings → Swift Compiler - General
- Objective-C Bridging Header:
BlincApp/Blinc-Bridging-Header.h
-
Add required frameworks:
- Metal.framework
- MetalKit.framework
- QuartzCore.framework
Swift Integration
Bridging Header
The bridging header (Blinc-Bridging-Header.h) declares the C FFI functions:
// Context lifecycle
IOSRenderContext* blinc_create_context(uint32_t width, uint32_t height, double scale);
void blinc_destroy_context(IOSRenderContext* ctx);
// Rendering
bool blinc_needs_render(IOSRenderContext* ctx);
void blinc_build_frame(IOSRenderContext* ctx);
bool blinc_render_frame(IOSGpuRenderer* gpu);
// Input
void blinc_handle_touch(IOSRenderContext* ctx, uint64_t id, float x, float y, int32_t phase);
View Controller
The BlincViewController manages:
- CADisplayLink for 60fps frame timing
- Metal layer for GPU rendering
- Touch event forwarding to Rust
Touch Event Handling
iOS touch events are routed through the view controller:
| iOS Phase | Blinc Event |
|---|---|
| touchesBegan | pointer_down |
| touchesMoved | pointer_move |
| touchesEnded | pointer_up + pointer_leave |
| touchesCancelled | pointer_leave |
The pointer_leave after pointer_up is important for proper button state transitions on touch devices.
Debugging
Console Logs
View Rust logs in Xcode’s console or use Console.app with a filter:
subsystem:com.blinc.my_app
Common Issues
“Library not found: -lmy_app”
Run the build script first:
./build-ios.sh
Black screen on simulator
- Ensure you built for the correct simulator target (
aarch64-apple-ios-sim) - Verify the library is in
libs/simulator/ - Check Xcode console for Metal initialization errors
Touch events not working
- Verify
blinc_create_contextsucceeds (check console logs) - Ensure
ios_app_init()is called before creating the context - Check that touch coordinates are in logical points, not pixels
Performance Tips
-
Use release builds for performance testing:
./build-ios.sh release -
Enable LTO in Cargo.toml:
[profile.release] lto = "thin" opt-level = "z" strip = true -
Test on real devices - simulators use software rendering for some operations
-
Profile with Instruments - use Xcode’s Metal debugger for GPU analysis
CLI Reference
The Blinc CLI simplifies creating and building mobile projects.
Creating a Project
New Project
blinc new my-app --template rust
This creates a new Blinc project with:
- Cargo.toml configured for mobile targets
- Platform directories for Android and iOS
- Build scripts for each platform
- Example UI code
Options
blinc new <name> [options]
Options:
--template <type> Project template (rust, swift, kotlin)
--platforms <list> Target platforms (desktop,android,ios)
--no-git Skip git initialization
Building
Build for Android
blinc build android
Options:
blinc build android [options]
Options:
--release Build in release mode
--target <arch> Target architecture (arm64-v8a, armeabi-v7a, x86_64, x86)
--all-targets Build for all architectures
Build for iOS
blinc build ios
Options:
blinc build ios [options]
Options:
--release Build in release mode
--device Build for physical device only
--simulator Build for simulator only
Running
Run on Android
blinc run android
This will:
- Build the native library
- Build the APK with Gradle
- Install on connected device/emulator
- Launch the app
Options:
blinc run android [options]
Options:
--release Run release build
--device <id> Target specific device (from adb devices)
--no-install Build only, don't install
Run on iOS
blinc run ios
This will:
- Build the static library
- Open Xcode project
- Build and run on selected target
Options:
blinc run ios [options]
Options:
--release Run release build
--simulator <name> Target specific simulator
--device Run on physical device
Project Configuration
blinc.toml
The project configuration file:
[project]
name = "my-app"
version = "0.1.0"
template = "rust"
entry = "Cargo.toml"
[targets]
default = "desktop"
supported = ["desktop", "android", "ios"]
[targets.desktop]
enabled = true
command = "cargo run --features desktop"
[targets.android]
enabled = true
platform_dir = "platforms/android"
[targets.ios]
enabled = true
platform_dir = "platforms/ios"
[build]
blinc_path = "../.." # Path to Blinc framework
Configuration Options
| Key | Description | Default |
|---|---|---|
project.name | Project name | Required |
project.version | Version string | “0.1.0” |
project.template | Template type | “rust” |
targets.default | Default build target | “desktop” |
targets.supported | List of supported platforms | [“desktop”] |
build.blinc_path | Path to Blinc framework | “../..” |
Cleaning
# Clean all build artifacts
blinc clean
# Clean specific platform
blinc clean android
blinc clean ios
Checking Configuration
# Validate project configuration
blinc check
# Check specific platform setup
blinc check android
blinc check ios
This verifies:
- Required tools are installed
- Environment variables are set
- Project configuration is valid
Elements & Layout
Blinc provides a set of core elements that can be composed to build any UI. All elements implement the ElementBuilder trait and use a fluent builder pattern.
Core Elements
Div - The Universal Container
div() is the primary building block. It’s a flexible container that supports:
- Flexbox layout
- Background colors and materials
- Borders and shadows
- Event handling
- Child elements
#![allow(unused)]
fn main() {
div()
.w(200.0)
.h(100.0)
.bg(Color::rgba(0.2, 0.2, 0.3, 1.0))
.rounded(8.0)
.flex_center()
.child(text("Hello"))
}
Text - Typography
text(content) renders text with customizable typography:
#![allow(unused)]
fn main() {
text("Hello, World!")
.size(24.0)
.weight(FontWeight::Bold)
.color(Color::WHITE)
.family("Inter")
}
Text Properties:
.size(px)- Font size in pixels.weight(FontWeight)- Bold, SemiBold, Medium, Regular, Light.color(Color)- Text color.family(name)- Font family.italic()- Italic style.underline()- Underline decoration.line_height(multiplier)- Line height as multiplier of font size.letter_spacing(px)- Space between characters.align(TextAlign)- Left, Center, Right, Justify
Typography Helpers:
#![allow(unused)]
fn main() {
h1("Heading 1") // 32px bold
h2("Heading 2") // 28px bold
h3("Heading 3") // 24px bold
h4("Heading 4") // 20px semibold
h5("Heading 5") // 16px semibold
h6("Heading 6") // 14px semibold
p("Paragraph") // 14px regular
caption("Caption") // 12px regular
label("Label") // 14px medium
muted("Muted text") // Reduced opacity
b("Bold text") // Bold weight
small("Small") // 12px
}
Stack - Overlapping Layers
stack() positions children on top of each other, useful for overlays and layered designs:
#![allow(unused)]
fn main() {
stack()
.w(200.0)
.h(200.0)
// Background layer
.child(
div().w_full().h_full().bg(Color::BLUE)
)
// Foreground layer
.child(
div()
.absolute()
.right(10.0)
.bottom(10.0)
.w(50.0)
.h(50.0)
.bg(Color::RED)
)
}
Canvas - Custom Drawing
canvas(render_fn) provides direct GPU drawing access:
#![allow(unused)]
fn main() {
canvas(|ctx: &mut dyn DrawContext, bounds| {
ctx.fill_rect(
Rect::new(0.0, 0.0, bounds.width, bounds.height),
CornerRadius::uniform(8.0),
Brush::Solid(Color::RED),
);
})
.w(200.0)
.h(100.0)
}
See Canvas Drawing for more details.
Image & SVG
#![allow(unused)]
fn main() {
// Raster images
image("path/to/image.png")
.w(200.0)
.h(150.0)
.cover() // Object-fit: cover
// SVG with tint
svg("path/to/icon.svg")
.w(24.0)
.h(24.0)
.tint(Color::WHITE)
}
See Images & SVG for more details.
Layout System
Blinc uses Flexbox for layout, powered by Taffy.
Sizing
#![allow(unused)]
fn main() {
div()
.w(200.0) // Fixed width in pixels
.h(100.0) // Fixed height in pixels
.w_full() // 100% width
.h_full() // 100% height
.w_auto() // Auto width (content-based)
.h_auto() // Auto height (content-based)
.w_fit() // Shrink-wrap to content
.size(200.0, 100.0) // Set both dimensions
.square(100.0) // Square element
.min_w(50.0) // Minimum width
.max_w(500.0) // Maximum width
.min_h(50.0) // Minimum height
.max_h(300.0) // Maximum height
.aspect_ratio(16.0 / 9.0) // Maintain aspect ratio
}
Flex Container
#![allow(unused)]
fn main() {
div()
.flex() // Enable flexbox
.flex_row() // Horizontal layout (default)
.flex_col() // Vertical layout
.flex_row_reverse() // Right to left
.flex_col_reverse() // Bottom to top
.flex_wrap() // Wrap children
}
Flex Items
#![allow(unused)]
fn main() {
div()
.flex_grow() // Grow to fill space (flex-grow: 1)
.flex_shrink() // Allow shrinking (flex-shrink: 1)
.flex_shrink_0() // Don't shrink (flex-shrink: 0)
.flex_1() // flex: 1 1 0% (grow and shrink)
.flex_auto() // flex: 1 1 auto
}
Alignment
Align Items (cross-axis alignment):
#![allow(unused)]
fn main() {
div()
.items_start() // Align to start
.items_center() // Center alignment
.items_end() // Align to end
.items_stretch() // Stretch to fill
.items_baseline() // Align baselines
}
Justify Content (main-axis distribution):
#![allow(unused)]
fn main() {
div()
.justify_start() // Pack at start
.justify_center() // Center items
.justify_end() // Pack at end
.justify_between() // Space between items
.justify_around() // Space around items
.justify_evenly() // Equal spacing
}
Convenience Methods:
#![allow(unused)]
fn main() {
div().flex_center() // Center both axes
div().flex_col().justify_center().items_center() // Same as above
}
Gap (Spacing Between Children)
#![allow(unused)]
fn main() {
div()
.gap(16.0) // Gap in pixels
.gap_x(8.0) // Horizontal gap only
.gap_y(12.0) // Vertical gap only
}
Padding
Padding uses a 4px unit system (like Tailwind CSS):
#![allow(unused)]
fn main() {
div()
.p(4.0) // 16px padding all sides (4 * 4px)
.px(2.0) // 8px horizontal padding
.py(3.0) // 12px vertical padding
.pt(1.0) // 4px top padding
.pr(2.0) // 8px right padding
.pb(3.0) // 12px bottom padding
.pl(4.0) // 16px left padding
.p_px(20.0) // 20px (exact pixels, not units)
}
Margin
Same unit system as padding:
#![allow(unused)]
fn main() {
div()
.m(4.0) // 16px margin all sides
.mx(2.0) // 8px horizontal margin
.my(3.0) // 12px vertical margin
.mt(1.0) // 4px top margin
.mr(2.0) // 8px right margin
.mb(3.0) // 12px bottom margin
.ml(4.0) // 16px left margin
.mx_auto() // Auto horizontal margins (centering)
}
Positioning
#![allow(unused)]
fn main() {
div()
.relative() // Position relative
.absolute() // Position absolute
.inset(10.0) // 10px from all edges
.top(20.0) // 20px from top
.right(20.0) // 20px from right
.bottom(20.0) // 20px from bottom
.left(20.0) // 20px from left
}
Overflow
#![allow(unused)]
fn main() {
div()
.overflow_clip() // Clip overflowing content
.overflow_visible() // Allow overflow
.overflow_scroll() // Enable scrolling
}
Common Layout Patterns
Centered Content
#![allow(unused)]
fn main() {
div()
.w_full()
.h_full()
.flex_center()
.child(content)
}
Sidebar Layout
#![allow(unused)]
fn main() {
div()
.w_full()
.h_full()
.flex_row()
.child(
div().w(250.0).h_full() // Sidebar
)
.child(
div().flex_1().h_full() // Main content
)
}
Card Grid
#![allow(unused)]
fn main() {
div()
.w_full()
.flex_row()
.flex_wrap()
.gap(16.0)
.child(card().w(300.0))
.child(card().w(300.0))
.child(card().w(300.0))
}
Header/Content/Footer
#![allow(unused)]
fn main() {
div()
.w_full()
.h_full()
.flex_col()
.child(
div().h(60.0).w_full() // Header
)
.child(
div().flex_1().w_full() // Content (fills remaining)
)
.child(
div().h(40.0).w_full() // Footer
)
}
Horizontal Navigation
#![allow(unused)]
fn main() {
div()
.w_full()
.h(60.0)
.flex_row()
.items_center()
.justify_between()
.px(4.0)
.child(logo())
.child(
div()
.flex_row()
.gap(24.0)
.child(nav_item("Home"))
.child(nav_item("About"))
.child(nav_item("Contact"))
)
}
The .child() Pattern
Add children with .child(). For multiple children of the same type, use iterators:
#![allow(unused)]
fn main() {
// Single child
div().child(text("Hello"))
// Multiple children
div()
.child(text("First"))
.child(text("Second"))
.child(text("Third"))
// From iterator
let items = vec!["Apple", "Banana", "Cherry"];
div().child(
items.into_iter().map(|item| text(item))
)
}
ElementBuilder Trait
All elements implement ElementBuilder:
#![allow(unused)]
fn main() {
pub trait ElementBuilder {
fn build(self, tree: &mut LayoutTree) -> LayoutNodeId;
}
}
This allows composing any element type:
#![allow(unused)]
fn main() {
fn my_component() -> impl ElementBuilder {
div().child(text("Hello"))
}
// Use it
div().child(my_component())
}
Styling & Materials
Blinc provides comprehensive styling options from simple colors to advanced GPU-accelerated material effects.
Colors
Basic Colors
Colors are RGBA with values from 0.0 to 1.0:
#![allow(unused)]
fn main() {
// RGBA constructor
Color::rgba(0.2, 0.4, 0.8, 1.0) // Blue, fully opaque
Color::rgba(1.0, 0.0, 0.0, 0.5) // Red, 50% transparent
// From array (common pattern)
Color::from([0.2, 0.4, 0.8, 1.0])
// Predefined colors
Color::WHITE
Color::BLACK
Color::RED
Color::GREEN
Color::BLUE
Color::TRANSPARENT
}
Background Colors
#![allow(unused)]
fn main() {
div()
.bg(Color::rgba(0.1, 0.1, 0.15, 1.0))
// From array shorthand
div().bg([0.1, 0.1, 0.15, 1.0])
}
Gradients
For gradients, use the .background() method with a Brush:
#![allow(unused)]
fn main() {
use blinc_core::{Brush, Gradient, GradientStop, Point};
div()
.w(200.0)
.h(100.0)
.background(Brush::Gradient(Gradient::linear_with_stops(
Point::new(0.0, 0.0), // Start point
Point::new(200.0, 0.0), // End point
vec![
GradientStop::new(0.0, Color::rgba(0.9, 0.2, 0.5, 1.0)),
GradientStop::new(0.5, Color::rgba(0.9, 0.5, 0.2, 1.0)),
GradientStop::new(1.0, Color::rgba(0.2, 0.8, 0.6, 1.0)),
],
)))
}
Borders & Corners
Corner Radius
#![allow(unused)]
fn main() {
div()
.rounded(8.0) // Uniform radius
.rounded_full() // Pill shape (50% of smallest dimension)
.rounded_corners(
16.0, // Top-left
16.0, // Top-right
8.0, // Bottom-right
8.0, // Bottom-left
)
}
Shadows
Preset Shadows
#![allow(unused)]
fn main() {
div()
.shadow_sm() // Small shadow
.shadow_md() // Medium shadow
.shadow_lg() // Large shadow
.shadow_xl() // Extra large shadow
}
Custom Shadows
#![allow(unused)]
fn main() {
div().shadow_params(
2.0, // Offset X
4.0, // Offset Y
12.0, // Blur radius
Color::rgba(0.0, 0.0, 0.0, 0.3)
)
}
Opacity
#![allow(unused)]
fn main() {
div()
.opacity(0.5) // 50% opacity
.opaque() // opacity: 1.0
.translucent() // opacity: 0.5
.invisible() // opacity: 0.0
}
Transforms
Apply 2D transforms to any element:
#![allow(unused)]
fn main() {
div()
.translate(10.0, 20.0) // Move by (x, y)
.scale(1.5) // Uniform scale
.scale_xy(1.5, 0.8) // Non-uniform scale
.rotate(45.0_f32.to_radians()) // Rotate (radians)
.rotate_deg(45.0) // Rotate (degrees)
}
For combined transforms:
#![allow(unused)]
fn main() {
use blinc_core::Transform;
div().transform(
Transform::translate(100.0, 50.0)
.then_scale(1.2, 1.2)
.then_rotate(0.1)
)
}
Materials
Blinc includes GPU-accelerated material effects for modern, polished UIs.
Glass Material
Creates a frosted glass effect with background blur:
#![allow(unused)]
fn main() {
// Quick glass
div().glass()
// Customized glass
use blinc_core::GlassMaterial;
div().material(Material::Glass(
GlassMaterial::new()
.blur(20.0) // Blur intensity (0-50)
.tint(Color::rgba(1.0, 1.0, 1.0, 0.1))
.saturation(1.2) // Color saturation
.brightness(1.0) // Brightness adjustment
.noise(0.03) // Frosted texture
.border(0.8) // Border highlight intensity
))
}
Glass Presets:
#![allow(unused)]
fn main() {
GlassMaterial::ultra_thin() // Very subtle
GlassMaterial::thin() // Light blur
GlassMaterial::regular() // Standard (default)
GlassMaterial::thick() // Heavy blur
GlassMaterial::frosted() // Frosted window style
GlassMaterial::card() // Card-like appearance
}
Metallic Material
Creates reflective metallic surfaces:
#![allow(unused)]
fn main() {
use blinc_core::MetallicMaterial;
div().material(Material::Metallic(
MetallicMaterial::new()
.color(Color::WHITE)
.roughness(0.3) // 0 = mirror, 1 = matte
.metallic(1.0) // Metal intensity
.reflection(0.5) // Reflection strength
))
}
Metallic Presets:
#![allow(unused)]
fn main() {
MetallicMaterial::chrome() // Polished chrome
MetallicMaterial::brushed() // Brushed metal
MetallicMaterial::gold() // Gold finish
MetallicMaterial::silver() // Silver finish
MetallicMaterial::copper() // Copper finish
}
Quick Material Methods
#![allow(unused)]
fn main() {
div().glass() // Default glass material
div().metallic() // Default metallic material
div().chrome() // Chrome preset
div().gold() // Gold preset
}
Render Layers
Control rendering order with layers:
#![allow(unused)]
fn main() {
use blinc_core::RenderLayer;
div()
.layer(RenderLayer::Background) // Rendered first
.child(background_content())
div()
.layer(RenderLayer::Foreground) // Rendered on top
.child(overlay_content())
}
For glass effects, content behind glass should be on .background() layer:
#![allow(unused)]
fn main() {
stack()
.child(
div().background() // Behind glass
.child(colorful_background())
)
.child(
div().glass() // Glass overlay
.foreground() // On top
.child(content())
)
}
Common Styling Patterns
Card Style
#![allow(unused)]
fn main() {
fn card() -> Div {
div()
.p(16.0)
.rounded(12.0)
.bg(Color::rgba(0.15, 0.15, 0.2, 1.0))
.shadow_md()
}
}
Glass Card
#![allow(unused)]
fn main() {
fn glass_card() -> Div {
div()
.p(16.0)
.rounded(16.0)
.glass()
.shadow_lg()
}
}
Button Styles
#![allow(unused)]
fn main() {
fn primary_button() -> Div {
div()
.px(4.0)
.py(2.0)
.rounded(8.0)
.bg(Color::rgba(0.3, 0.5, 0.9, 1.0))
}
fn secondary_button() -> Div {
div()
.px(4.0)
.py(2.0)
.rounded(8.0)
.bg(Color::rgba(0.2, 0.2, 0.25, 1.0))
}
fn ghost_button() -> Div {
div()
.px(4.0)
.py(2.0)
.rounded(8.0)
.bg(Color::TRANSPARENT)
}
}
Hover Effects with State
Use stateful(handle) to create elements with automatic hover/press state transitions:
#![allow(unused)]
fn main() {
use blinc_layout::stateful::stateful;
fn hoverable_card(ctx: &WindowedContext) -> impl ElementBuilder {
let handle = ctx.use_state(ButtonState::Idle);
stateful(handle)
.p(16.0)
.rounded(12.0)
.on_state(|state, div| {
let bg = match state {
ButtonState::Idle => Color::rgba(0.15, 0.15, 0.2, 1.0),
ButtonState::Hovered => Color::rgba(0.18, 0.18, 0.24, 1.0),
ButtonState::Pressed => Color::rgba(0.12, 0.12, 0.16, 1.0),
_ => Color::rgba(0.15, 0.15, 0.2, 1.0),
};
div.set_bg(bg);
})
.child(text("Hover me").color(Color::WHITE))
}
}
Dark Theme Color Palette
Common colors for dark-themed UIs:
#![allow(unused)]
fn main() {
// Backgrounds
let bg_primary = Color::rgba(0.08, 0.08, 0.12, 1.0);
let bg_secondary = Color::rgba(0.12, 0.12, 0.16, 1.0);
let bg_tertiary = Color::rgba(0.16, 0.16, 0.2, 1.0);
// Surfaces
let surface = Color::rgba(0.15, 0.15, 0.2, 1.0);
let surface_hover = Color::rgba(0.18, 0.18, 0.24, 1.0);
// Text
let text_primary = Color::WHITE;
let text_secondary = Color::rgba(0.7, 0.7, 0.8, 1.0);
let text_muted = Color::rgba(0.5, 0.5, 0.6, 1.0);
// Accent
let accent = Color::rgba(0.4, 0.6, 1.0, 1.0);
let accent_hover = Color::rgba(0.5, 0.7, 1.0, 1.0);
// Status
let success = Color::rgba(0.2, 0.8, 0.4, 1.0);
let warning = Color::rgba(0.9, 0.7, 0.2, 1.0);
let error = Color::rgba(0.9, 0.3, 0.3, 1.0);
}
CSS Styling
Blinc includes a powerful CSS parser that allows you to define styles using familiar CSS syntax. This enables separation of concerns between layout code and visual styling.
Overview
The CSS system supports:
- ID-based selectors (
#element-id) - State modifiers (
:hover,:active,:focus,:disabled) - CSS custom properties (
:rootandvar()) - Keyframe animations (
@keyframes) - Automatic animation application via the
animation:property - Theme integration (
theme()function) - Length units (
px,sp,%) - Gradients (
linear-gradient,radial-gradient,conic-gradient)
Basic Usage
Parsing CSS
#![allow(unused)]
fn main() {
use blinc_layout::prelude::*;
let css = r#"
#card {
background: #3498db;
border-radius: 12px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
"#;
let result = Stylesheet::parse_with_errors(css);
// Check for errors
if result.has_errors() {
result.print_colored_diagnostics();
}
let stylesheet = result.stylesheet;
}
Applying Styles to Elements
Attach the stylesheet to the RenderTree:
#![allow(unused)]
fn main() {
use std::sync::Arc;
// In your render tree setup
render_tree.set_stylesheet(Some(Arc::new(stylesheet)));
// Then use IDs on elements
div()
.id("card")
.child(text("Styled with CSS!"))
}
Supported Properties
Background
#element {
background: #ff5733; /* Hex color */
background: rgb(255, 87, 51); /* RGB */
background: rgba(255, 87, 51, 0.8); /* RGBA */
background: theme(primary); /* Theme token */
}
Gradients
CSS gradients are fully supported for the background property:
Linear Gradients
#element {
/* Angle-based (0deg = up, 90deg = right, 180deg = down) */
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
/* Direction keywords */
background: linear-gradient(to right, red, blue);
background: linear-gradient(to bottom right, #fff, #000);
/* Multiple color stops */
background: linear-gradient(90deg, red 0%, yellow 50%, green 100%);
/* Implied positions (evenly distributed) */
background: linear-gradient(to bottom, red, yellow, green);
/* Different angle units */
background: linear-gradient(0.25turn, red, blue); /* 90deg */
background: linear-gradient(1.5708rad, red, blue); /* ~90deg */
}
Radial Gradients
#element {
/* Simple circle from center */
background: radial-gradient(circle, red, blue);
/* With position */
background: radial-gradient(circle at center, red, blue);
background: radial-gradient(circle at 25% 75%, red, blue);
/* Ellipse shape */
background: radial-gradient(ellipse at center, red, blue);
/* Multiple color stops */
background: radial-gradient(circle, red 0%, yellow 50%, green 100%);
}
Conic Gradients
#element {
/* Simple color wheel */
background: conic-gradient(red, yellow, green, blue, red);
/* With starting angle */
background: conic-gradient(from 45deg, red, blue);
/* With position */
background: conic-gradient(at 25% 75%, red, blue);
/* Combined angle and position */
background: conic-gradient(from 90deg at center, red, blue);
}
Gradient Color Stops
Color stops can use any supported color format:
#element {
/* Hex colors with positions */
background: linear-gradient(to right, #667eea 0%, #764ba2 100%);
/* RGBA colors */
background: linear-gradient(45deg, rgba(255, 0, 0, 0.5), rgba(0, 0, 255, 0.8));
/* Named colors */
background: linear-gradient(to right, red, orange, yellow, green, blue);
/* Mixed formats */
background: linear-gradient(135deg, #ff0000, rgba(0, 255, 0, 0.5) 50%, blue);
}
Border Radius
#element {
border-radius: 8px; /* Uniform */
border-radius: theme(radius-lg); /* Theme token */
}
Box Shadow
#element {
box-shadow: 2px 4px 12px rgba(0, 0, 0, 0.3); /* x y blur color */
box-shadow: theme(shadow-md); /* Theme token */
box-shadow: none; /* Remove shadow */
}
Transform
#element {
transform: scale(1.02); /* Uniform scale */
transform: scale(1.5, 0.8); /* Non-uniform */
transform: translate(10px, 20px); /* Translation */
transform: translateX(10px); /* X only */
transform: translateY(20px); /* Y only */
transform: rotate(45deg); /* Rotation */
}
Opacity
#element {
opacity: 0.8;
}
Render Layer
#element {
render-layer: foreground; /* On top */
render-layer: background; /* Behind */
render-layer: glass; /* Glass layer */
}
Length Units
Blinc CSS supports three types of length units:
Pixels (px)
Raw pixel values. These are the default when no unit is specified.
#element {
border-radius: 8px;
box-shadow: 2px 4px 12px rgba(0, 0, 0, 0.3);
transform: translate(10px, 20px);
}
Spacing Units (sp)
Spacing units follow a 4px grid system, where 1sp = 4px. This helps maintain consistent spacing throughout your application.
#card {
border-radius: 2sp; /* 2 * 4 = 8px */
box-shadow: 1sp 2sp 4sp rgba(0,0,0,0.2); /* 4px 8px 16px */
transform: translate(4sp, 2sp); /* 16px, 8px */
}
Common sp values:
1sp= 4px2sp= 8px4sp= 16px6sp= 24px8sp= 32px
Percentages (%)
Percentages are supported in gradient color stops and position values.
#element {
/* Gradient color stops use percentages */
background: linear-gradient(to right, red 0%, blue 100%);
/* Radial/conic gradient positions */
background: radial-gradient(circle at 25% 75%, red, blue);
}
State Modifiers
Define different styles for interactive states:
#button {
background: theme(primary);
transform: scale(1.0);
}
#button:hover {
background: theme(primary-hover);
transform: scale(1.02);
}
#button:active {
transform: scale(0.98);
}
#button:focus {
box-shadow: 0 0 0 3px theme(primary);
}
#button:disabled {
opacity: 0.5;
}
Querying State Styles
#![allow(unused)]
fn main() {
// Get base style
let base = stylesheet.get("button");
// Get state-specific style
let hover = stylesheet.get_with_state("button", CssElementState::Hover);
let active = stylesheet.get_with_state("button", CssElementState::Active);
// Get all states at once
let (base, states) = stylesheet.get_all_states("button");
for (state, style) in states {
println!(":{} => {:?}", state, style.opacity);
}
}
CSS Variables
Define reusable values with custom properties:
:root {
--brand-color: #3498db;
--hover-opacity: 0.85;
--card-radius: 12px;
--spacing-md: 16px;
}
#card {
background: var(--brand-color);
border-radius: var(--card-radius);
opacity: 1.0;
}
#card:hover {
opacity: var(--hover-opacity);
}
Fallback Values
#element {
background: var(--undefined-color, #333); /* Uses fallback */
}
Accessing Variables Programmatically
#![allow(unused)]
fn main() {
// Get a variable value
if let Some(value) = stylesheet.get_variable("brand-color") {
println!("Brand color: {}", value);
}
// Iterate all variables
for name in stylesheet.variable_names() {
let value = stylesheet.get_variable(name).unwrap();
println!("--{}: {}", name, value);
}
}
Theme Integration
Use the theme() function to reference theme tokens:
#card {
background: theme(surface);
border-radius: theme(radius-lg);
box-shadow: theme(shadow-md);
}
#button {
background: theme(primary);
}
#button:hover {
background: theme(primary-hover);
}
Available Theme Tokens
Colors:
primary,primary-hover,primary-activesecondary,secondary-hover,secondary-activesuccess,success-bgwarning,warning-bgerror,error-bginfo,info-bgforeground,foreground-mutedbackground,surface,surface-hoverborder,border-muted
Radii:
radius-sm,radius-default,radius-mdradius-lg,radius-xl,radius-2xl
Shadows:
shadow-sm,shadow-default,shadow-mdshadow-lg,shadow-xl
Keyframe Animations
Define complex animations with @keyframes:
@keyframes fade-in {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes pulse {
0%, 100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.8;
transform: scale(1.05);
}
}
Percentage Positions
@keyframes complex-animation {
0% { opacity: 0; }
25% { opacity: 0.5; transform: scale(1.1); }
50% { opacity: 1; }
75% { opacity: 0.5; transform: scale(0.9); }
100% { opacity: 1; transform: scale(1); }
}
Accessing Keyframes
#![allow(unused)]
fn main() {
// Get keyframes by name
if let Some(keyframes) = stylesheet.get_keyframes("fade-in") {
println!("Animation has {} stops", keyframes.keyframes.len());
for kf in &keyframes.keyframes {
println!(" {}%: opacity={:?}",
(kf.position * 100.0) as i32,
kf.style.opacity
);
}
}
}
Converting to Motion Animation
#![allow(unused)]
fn main() {
// Convert to MotionAnimation (for simple from/to animations)
let motion = keyframes.to_motion_animation(300, 200); // enter_ms, exit_ms
// Convert to MultiKeyframeAnimation (for complex multi-step animations)
let animation = keyframes.to_multi_keyframe_animation(1000, Easing::EaseInOut);
}
Animation Property
Apply animations to elements with the animation: property:
@keyframes slide-in {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
#modal {
animation: slide-in 300ms ease-out;
}
Animation Shorthand
#element {
/* animation: name duration timing-function delay iteration-count direction fill-mode */
animation: pulse 2s ease-in-out 100ms infinite alternate forwards;
}
Individual Properties
#element {
animation-name: pulse;
animation-duration: 2s;
animation-timing-function: ease-in-out;
animation-delay: 100ms;
animation-iteration-count: infinite; /* or a number */
animation-direction: alternate; /* normal | reverse | alternate | alternate-reverse */
animation-fill-mode: forwards; /* none | forwards | backwards | both */
}
Automatic Application
When a stylesheet is attached to the RenderTree, elements with IDs automatically receive animations:
#![allow(unused)]
fn main() {
let css = r#"
@keyframes card-enter {
from { opacity: 0; transform: scale(0.95); }
to { opacity: 1; transform: scale(1); }
}
#card {
animation: card-enter 300ms ease-out;
}
"#;
let stylesheet = Stylesheet::parse_with_errors(css).stylesheet;
render_tree.set_stylesheet(Some(Arc::new(stylesheet)));
// This element will automatically animate on render!
div()
.id("card")
.child(content())
}
Error Handling
The CSS parser collects errors without failing:
#![allow(unused)]
fn main() {
let css = r#"
#card {
background: red;
opacity: invalid; /* Error: invalid value */
unknown-prop: foo; /* Warning: unknown property */
}
"#;
let result = Stylesheet::parse_with_errors(css);
// Check for issues
if result.has_errors() {
println!("Has {} error(s)", result.errors_only().count());
}
if result.has_warnings() {
println!("Has {} warning(s)", result.warnings_only().count());
}
// Print colored diagnostics to console
result.print_colored_diagnostics();
result.print_summary();
// The valid properties are still parsed!
let style = result.stylesheet.get("card").unwrap();
assert!(style.background.is_some()); // "red" was parsed
}
Error Information
#![allow(unused)]
fn main() {
for error in &result.errors {
println!("Line {}, Column {}: {}",
error.line,
error.column,
error.message
);
if let Some(ref prop) = error.property {
println!(" Property: {}", prop);
}
if let Some(ref val) = error.value {
println!(" Value: {}", val);
}
}
}
Motion Container Integration
Use CSS keyframes with the Motion container:
#![allow(unused)]
fn main() {
let css = r#"
@keyframes modal-enter {
from { opacity: 0; transform: scale(0.9) translateY(20px); }
to { opacity: 1; transform: scale(1) translateY(0); }
}
"#;
let stylesheet = Stylesheet::parse_with_errors(css).stylesheet;
// Method 1: Using from_stylesheet
motion()
.from_stylesheet(&stylesheet, "modal-enter", 300, 200)
.child(modal_content())
// Method 2: Using keyframes_from_stylesheet for multi-step animations
motion()
.keyframes_from_stylesheet(&stylesheet, "pulse", 1000, Easing::EaseInOut)
.child(pulsing_element())
}
Complete Example
#![allow(unused)]
fn main() {
use blinc_layout::prelude::*;
use std::sync::Arc;
fn styled_app() -> impl ElementBuilder {
// Define styles
let css = r#"
:root {
--card-bg: theme(surface);
--card-radius: theme(radius-lg);
--brand-gradient: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
@keyframes fade-in {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
#app-container {
background: theme(background);
}
#card {
background: var(--card-bg);
border-radius: var(--card-radius);
box-shadow: theme(shadow-md);
animation: fade-in 300ms ease-out;
}
#card:hover {
box-shadow: theme(shadow-lg);
transform: translateY(-2px);
}
#gradient-card {
background: var(--brand-gradient);
border-radius: theme(radius-lg);
box-shadow: theme(shadow-md);
}
#gradient-card:hover {
background: linear-gradient(135deg, #7c8ff0 0%, #8b5cb8 100%);
transform: translateY(-2px);
}
#primary-button {
background: theme(primary);
border-radius: theme(radius-default);
}
#primary-button:hover {
background: theme(primary-hover);
transform: scale(1.02);
}
#primary-button:active {
transform: scale(0.98);
}
"#;
let result = Stylesheet::parse_with_errors(css);
if result.has_errors() {
result.print_colored_diagnostics();
}
// In real usage, attach to render_tree
// render_tree.set_stylesheet(Some(Arc::new(result.stylesheet)));
div()
.id("app-container")
.flex_col()
.p(24.0)
.gap(16.0)
.child(
div()
.id("card")
.p(16.0)
.child(text("Styled with CSS!"))
)
.child(
div()
.id("gradient-card")
.p(16.0)
.child(text("Gradient background!"))
)
.child(
button("Click me")
.id("primary-button")
)
}
}
Best Practices
- Use CSS variables for values you want to reuse or override
- Use theme tokens for colors that should respect the app’s theme
- Check for errors after parsing to catch typos and invalid values
- Keep animations short for UI transitions (150-400ms)
- Use state modifiers for hover/active effects instead of manual callbacks
- Prefer ID selectors (
#id) for precise targeting
Comparison with Builder API
| CSS | Builder API |
|---|---|
background: #3498db; | .bg(Color::hex("#3498db")) |
border-radius: 8px; | .rounded(8.0) |
transform: scale(1.02); | .scale(1.02) |
opacity: 0.8; | .opacity(0.8) |
box-shadow: theme(shadow-md); | .shadow_md() |
Both approaches can be combined - use CSS for base styles and the builder API for dynamic values.
Theming
Blinc provides a comprehensive theming system with design tokens, light/dark mode support, animated theme transitions, and platform-native color scheme detection.
Overview
The theming system is built around these core concepts:
- Design Tokens: Semantic color, typography, spacing, and radius values
- ThemeState: Global singleton for theme access and switching
- Animated Transitions: Smooth spring-based color interpolation between themes
- Platform Detection: Automatic system dark/light mode detection
Quick Start
Accessing Theme Tokens
#![allow(unused)]
fn main() {
use blinc_theme::{ThemeState, ColorToken};
fn my_component() -> impl ElementBuilder {
let theme = ThemeState::get();
// Get semantic colors
let bg = theme.color(ColorToken::Background);
let text = theme.color(ColorToken::TextPrimary);
let primary = theme.color(ColorToken::Primary);
// Get spacing values
let padding = theme.spacing().space_4;
// Get typography
let font_size = theme.typography().text_base;
// Get border radius
let radius = theme.radii().radius_lg;
div()
.bg(bg)
.p(padding)
.rounded(radius)
.child(
text("Hello, themed world!")
.size(font_size)
.color(text)
)
}
}
Toggling Color Scheme
⚠️ Known Limitation: Dynamic Theme Toggle
Dynamic theme switching at runtime (e.g., toggling between light/dark mode while the app is running) currently has significant limitations:
- Full UI rebuild required: Theme changes trigger a complete UI tree rebuild, which is expensive and can cause visual glitches
on_readycallbacks fire multiple times: During theme animation,on_readymay fire repeatedly instead of once- Animation ticks cause rebuilds: Each frame of the theme transition animation triggers another rebuild
Recommendation: For production apps, set the theme once at startup based on user preference or system settings. Theme changes should require an app restart.
This limitation will be addressed in a future release with token-based color resolution that allows visual-only repaints without tree rebuilds.
#![allow(unused)]
fn main() {
// Toggle between light and dark mode
ThemeState::get().toggle_scheme();
// Or set explicitly
use blinc_theme::ColorScheme;
ThemeState::get().set_scheme(ColorScheme::Dark);
ThemeState::get().set_scheme(ColorScheme::Light);
// Check current scheme
let scheme = ThemeState::get().scheme();
match scheme {
ColorScheme::Light => { /* ... */ }
ColorScheme::Dark => { /* ... */ }
}
}
Color Tokens
Color tokens provide semantic meaning to colors, making it easy to build consistent UIs that adapt to theme changes.
Token Categories
| Category | Tokens | Description |
|---|---|---|
| Brand | Primary, PrimaryHover, PrimaryActive, Secondary, SecondaryHover, SecondaryActive | Main brand colors |
| Semantic | Success, Warning, Error, Info + *Bg variants | Status/feedback colors |
| Surface | Background, Surface, SurfaceElevated, SurfaceOverlay | Background layers |
| Text | TextPrimary, TextSecondary, TextTertiary, TextInverse, TextLink | Text colors |
| Border | Border, BorderHover, BorderFocus, BorderError | Border states |
| Input | InputBg, InputBgHover, InputBgFocus, InputBgDisabled | Form input backgrounds |
| Selection | Selection, SelectionText | Text selection colors |
| Accent | Accent, AccentSubtle | Accent highlights |
Usage Example
#![allow(unused)]
fn main() {
use blinc_theme::{ThemeState, ColorToken};
fn themed_card() -> impl ElementBuilder {
let theme = ThemeState::get();
div()
.bg(theme.color(ColorToken::Surface))
.border(1.0, theme.color(ColorToken::Border))
.rounded(theme.radii().radius_lg)
.p(theme.spacing().space_4)
.child(
text("Card Title")
.size(theme.typography().text_lg)
.color(theme.color(ColorToken::TextPrimary))
)
.child(
text("Card description text")
.size(theme.typography().text_sm)
.color(theme.color(ColorToken::TextSecondary))
)
}
}
Typography Tokens
Typography tokens define a consistent type scale:
| Token | Size | Use Case |
|---|---|---|
text_xs | 12px | Captions, labels |
text_sm | 14px | Secondary text, buttons |
text_base | 16px | Body text |
text_lg | 18px | Large body text |
text_xl | 20px | Small headings |
text_2xl | 24px | Section headings |
text_3xl | 30px | Page headings |
text_4xl | 36px | Large headings |
text_5xl | 48px | Hero text |
#![allow(unused)]
fn main() {
let theme = ThemeState::get();
let typo = theme.typography();
text("Heading").size(typo.text_2xl)
text("Body").size(typo.text_base)
text("Caption").size(typo.text_xs)
}
Spacing Tokens
Spacing follows a 4px base scale for consistent rhythm:
| Token | Value | Use Case |
|---|---|---|
space_1 | 4px | Minimal spacing |
space_2 | 8px | Tight spacing |
space_2_5 | 10px | Between tight and standard |
space_3 | 12px | Standard small |
space_4 | 16px | Standard spacing |
space_5 | 20px | Medium spacing |
space_6 | 24px | Large spacing |
space_8 | 32px | Section spacing |
space_10 | 40px | Large section spacing |
space_12 | 48px | Extra large spacing |
#![allow(unused)]
fn main() {
let theme = ThemeState::get();
let spacing = theme.spacing();
div()
.p(spacing.space_4) // 16px padding
.gap(spacing.space_3) // 12px gap between children
.my(spacing.space_6) // 24px vertical margin
}
Radius Tokens
Border radius tokens for consistent rounded corners:
| Token | Value | Use Case |
|---|---|---|
radius_none | 0px | Sharp corners |
radius_sm | 4px | Subtle rounding |
radius_md | 6px | Standard rounding |
radius_lg | 8px | Pronounced rounding |
radius_xl | 12px | Large rounding |
radius_2xl | 16px | Extra large rounding |
radius_full | 9999px | Pill shape |
#![allow(unused)]
fn main() {
let theme = ThemeState::get();
div().rounded(theme.radii().radius_lg) // 8px corners
div().rounded(theme.radii().radius_full) // Pill shape
}
Animated Theme Transitions
⚠️ Experimental Feature
Animated theme transitions are currently experimental and have known issues. See the Known Limitation above. For production use, disable animations and require app restart for theme changes.
When switching between light and dark mode, colors smoothly animate using spring physics. This happens automatically when you call toggle_scheme() or set_scheme().
How It Works
- Theme colors are stored as
AnimatedValuein the globalThemeState - When the scheme changes, target colors animate from current to new values
- The animation scheduler drives smooth interpolation
- UI rebuilds on each frame with interpolated colors (⚠️ this is the source of current performance issues)
Configuration
The transition uses a gentle spring configuration for smooth, natural motion:
#![allow(unused)]
fn main() {
// Internal spring config for theme transitions
SpringConfig::gentle() // stiffness: 120, damping: 14
}
Reading Animated Colors
Colors are read during each render, automatically getting the interpolated value:
#![allow(unused)]
fn main() {
fn my_component() -> impl ElementBuilder {
let theme = ThemeState::get();
// This color will be interpolated during transitions
let bg = theme.color(ColorToken::Background);
div().bg(bg)
}
}
Important: Always read colors from ThemeState inside your component function, not captured in closures at initialization time. This ensures colors update during animations.
Reactive Theme Updates
For interactive elements that need to respond to theme changes within event handlers, fetch colors inside the callback:
#![allow(unused)]
fn main() {
fn themed_button(ctx: &WindowedContext) -> impl ElementBuilder {
let handle = ctx.use_state_for("btn", ButtonState::Idle);
stateful(handle)
.on_state(|state, div| {
// Fetch colors inside callback for theme reactivity
let theme = ThemeState::get();
let primary = theme.color(ColorToken::Primary);
let primary_hover = theme.color(ColorToken::PrimaryHover);
match state {
ButtonState::Idle => div.set_bg(primary),
ButtonState::Hovered => div.set_bg(primary_hover),
// ...
}
})
.child(text("Click me"))
}
}
Default Theme: Catppuccin
Blinc’s default theme is derived from Catppuccin, a community-driven pastel theme:
- Light mode: Catppuccin Latte
- Dark mode: Catppuccin Mocha
Latte (Light) Palette
| Role | Color |
|---|---|
| Background | #EFF1F5 |
| Surface | #FFFFFF |
| Text Primary | #4C4F69 |
| Primary | #1E66F5 (Blue) |
| Success | #40A02B (Green) |
| Warning | #DF8E1D (Yellow) |
| Error | #D20F39 (Red) |
Mocha (Dark) Palette
| Role | Color |
|---|---|
| Background | #1E1E2E |
| Surface | #313244 |
| Text Primary | #CDD6F4 |
| Primary | #89B4FA (Blue) |
| Success | #A6E3A1 (Green) |
| Warning | #F9E2AF (Yellow) |
| Error | #F38BA8 (Red) |
Platform Color Scheme Detection
Blinc automatically detects the system’s preferred color scheme on supported platforms:
| Platform | Detection Method |
|---|---|
| macOS | AppleInterfaceStyle from UserDefaults |
| Windows | Windows.UI.ViewManagement API |
| Linux | XDG/GTK settings |
| iOS | Native UITraitCollection |
| Android | Configuration.uiMode |
Manual Detection
#![allow(unused)]
fn main() {
use blinc_theme::platform::detect_system_color_scheme;
// Get system preference
let scheme = detect_system_color_scheme();
// Initialize theme with system preference
ThemeState::init(BlincTheme::bundle(), scheme);
}
The WindowedApp automatically initializes the theme with system color scheme detection.
System Scheme Watcher (Optional)
⚠️ Not Recommended for Production
Due to the dynamic theme toggle limitations, the system scheme watcher is not recommended for production apps. When the system theme changes, it triggers the same problematic full UI rebuild. Consider detecting the system scheme once at startup instead.
For apps that need to automatically follow system theme changes (e.g., when the user toggles dark mode in system settings), Blinc provides an optional background watcher.
Enabling the Feature
Add the watcher feature to your Cargo.toml:
[dependencies]
blinc_theme = { version = "0.1", features = ["watcher"] }
Basic Usage
#![allow(unused)]
fn main() {
use blinc_theme::{SystemSchemeWatcher, WatcherConfig};
use std::time::Duration;
// Start watching with default interval (1 second)
let watcher = SystemSchemeWatcher::start();
// Or with a custom polling interval
let watcher = SystemSchemeWatcher::start_with_interval(Duration::from_secs(5));
// The watcher runs in a background thread and automatically updates
// ThemeState when the system color scheme changes.
// Stop watching when done (or let it drop)
// watcher.stop();
}
Using WatcherConfig
#![allow(unused)]
fn main() {
use blinc_theme::WatcherConfig;
use std::time::Duration;
// Builder pattern for configuration
let watcher = WatcherConfig::new()
.poll_interval(Duration::from_secs(2)) // Check every 2 seconds
.auto_start(true) // Start immediately
.build();
}
How It Works
- The watcher runs in a background thread named
blinc-scheme-watcher - It polls the system color scheme at the configured interval
- When a change is detected, it calls
ThemeState::set_scheme()automatically - Theme transitions are animated smoothly using spring physics
- The watcher is thread-safe and cleans up when dropped
Use Cases
- Desktop apps: Follow system dark/light mode preference
- Long-running apps: Adapt to user changing system settings
- Kiosk/display apps: Automatically switch themes based on time of day (if OS supports scheduling)
Performance Notes
- The default 1-second interval is a good balance between responsiveness and CPU usage
- For less critical apps, consider using 5-10 second intervals
- The watcher thread sleeps between checks, consuming minimal resources
Dynamic Token Overrides
You can override individual tokens at runtime without changing the entire theme:
#![allow(unused)]
fn main() {
use blinc_theme::{ThemeState, ColorToken};
use blinc_core::Color;
// Override a specific color
ThemeState::get().set_color_override(
ColorToken::Primary,
Color::from_hex(0x6366F1) // Custom brand color
);
// Remove override (revert to theme default)
ThemeState::get().remove_color_override(ColorToken::Primary);
// Clear all overrides
ThemeState::get().clear_overrides();
}
Override Types
| Method | Triggers |
|---|---|
set_color_override() | Repaint only |
set_spacing_override() | Layout recompute |
set_radius_override() | Repaint only |
Building Themed Components
Pattern 1: Direct Token Access
Best for simple components:
#![allow(unused)]
fn main() {
fn simple_badge(label: &str) -> impl ElementBuilder {
let theme = ThemeState::get();
div()
.px(theme.spacing().space_2)
.py(theme.spacing().space_1)
.rounded(theme.radii().radius_md)
.bg(theme.color(ColorToken::AccentSubtle))
.child(
text(label)
.size(theme.typography().text_xs)
.color(theme.color(ColorToken::Accent))
)
}
}
Pattern 2: Themed Config Struct
Best for complex widgets with many options:
#![allow(unused)]
fn main() {
pub struct CardConfig {
pub padding: f32,
pub radius: f32,
pub show_shadow: bool,
}
impl CardConfig {
pub fn themed() -> Self {
let theme = ThemeState::get();
Self {
padding: theme.spacing().space_4,
radius: theme.radii().radius_lg,
show_shadow: true,
}
}
}
}
Pattern 3: Color Token Parameters
For components that accept different color variants:
#![allow(unused)]
fn main() {
fn status_badge(label: &str, color_token: ColorToken) -> impl ElementBuilder {
let theme = ThemeState::get();
let color = theme.color(color_token);
div()
.px(theme.spacing().space_2)
.py(theme.spacing().space_1)
.rounded(theme.radii().radius_full)
.bg(color.with_alpha(0.15))
.child(
text(label)
.size(theme.typography().text_xs)
.color(color)
)
}
// Usage
status_badge("Success", ColorToken::Success)
status_badge("Warning", ColorToken::Warning)
status_badge("Error", ColorToken::Error)
}
Best Practices
-
Always use semantic tokens - Use
ColorToken::Primaryinstead of hardcoded colors for automatic theme support. -
Read colors at render time - Access
ThemeState::get()inside your component function, not at module level. -
Fetch in callbacks - For
on_stateand other callbacks, fetch theme colors inside the callback to respond to theme changes. -
Use spacing scale - Use
theme.spacing().space_*for consistent visual rhythm. -
Match radius to context - Use smaller radii for small elements, larger for cards and panels.
-
Test both themes - Always verify your UI looks good in both light and dark modes.
Example: Complete Themed Component
#![allow(unused)]
fn main() {
use blinc_app::prelude::*;
use blinc_theme::{ThemeState, ColorToken};
fn notification_toast(
ctx: &WindowedContext,
message: &str,
variant: ColorToken,
) -> impl ElementBuilder {
let theme = ThemeState::get();
let handle = ctx.use_state_for("toast", ButtonState::Idle);
let bg_color = theme.color(variant);
stateful(handle)
.w(320.0)
.p(theme.spacing().space_4)
.rounded(theme.radii().radius_lg)
.bg(bg_color.with_alpha(0.15))
.border(1.0, bg_color.with_alpha(0.3))
.shadow_md()
.on_state(move |state, div| {
let theme = ThemeState::get();
let base = theme.color(variant);
match state {
ButtonState::Hovered => {
div.set_bg(base.with_alpha(0.2));
}
_ => {
div.set_bg(base.with_alpha(0.15));
}
}
})
.flex_row()
.items_center()
.gap(theme.spacing().space_3)
.child(
// Icon placeholder
div()
.w(24.0)
.h(24.0)
.rounded(theme.radii().radius_full)
.bg(bg_color)
)
.child(
text(message)
.size(theme.typography().text_sm)
.color(theme.color(ColorToken::TextPrimary))
)
}
// Usage
notification_toast(ctx, "File saved successfully", ColorToken::Success)
notification_toast(ctx, "Network error occurred", ColorToken::Error)
notification_toast(ctx, "New update available", ColorToken::Info)
}
Event Handling
Blinc provides event handling through closures attached to elements. Events bubble up from child to parent elements.
Available Events
Pointer Events
#![allow(unused)]
fn main() {
div()
.on_click(|ctx| {
println!("Clicked at ({}, {})", ctx.local_x, ctx.local_y);
})
.on_mouse_down(|ctx| {
println!("Mouse button pressed");
})
.on_mouse_up(|ctx| {
println!("Mouse button released");
})
}
Hover Events
#![allow(unused)]
fn main() {
div()
.on_hover_enter(|ctx| {
println!("Mouse entered element");
})
.on_hover_leave(|ctx| {
println!("Mouse left element");
})
}
Focus Events
#![allow(unused)]
fn main() {
div()
.on_focus(|ctx| {
println!("Element focused");
})
.on_blur(|ctx| {
println!("Element lost focus");
})
}
Keyboard Events
#![allow(unused)]
fn main() {
div()
.on_key_down(|ctx| {
println!("Key pressed: code={}", ctx.key_code);
if ctx.ctrl && ctx.key_code == 83 { // Ctrl+S
println!("Save shortcut triggered!");
}
})
.on_key_up(|ctx| {
println!("Key released");
})
.on_text_input(|ctx| {
if let Some(ch) = ctx.key_char {
println!("Character typed: {}", ch);
}
})
}
Scroll Events
#![allow(unused)]
fn main() {
div()
.on_scroll(|ctx| {
println!("Scrolled: dx={}, dy={}", ctx.scroll_delta_x, ctx.scroll_delta_y);
})
}
Drag Events
#![allow(unused)]
fn main() {
div()
.on_drag(|ctx| {
println!("Dragging: delta=({}, {})", ctx.drag_delta_x, ctx.drag_delta_y);
})
.on_drag_end(|ctx| {
println!("Drag ended");
})
}
Lifecycle Events
#![allow(unused)]
fn main() {
div()
.on_mount(|ctx| {
println!("Element added to tree");
})
.on_unmount(|ctx| {
println!("Element removed from tree");
})
.on_resize(|ctx| {
println!("Element resized");
})
}
EventContext
All event handlers receive an EventContext with information about the event:
#![allow(unused)]
fn main() {
pub struct EventContext {
pub event_type: EventType, // Type of event
pub node_id: LayoutNodeId, // Element that received the event
// Mouse position (global coordinates)
pub mouse_x: f32,
pub mouse_y: f32,
// Mouse position (relative to element)
pub local_x: f32,
pub local_y: f32,
// Scroll deltas (for SCROLL events)
pub scroll_delta_x: f32,
pub scroll_delta_y: f32,
// Drag deltas (for DRAG events)
pub drag_delta_x: f32,
pub drag_delta_y: f32,
// Keyboard (for KEY_DOWN, KEY_UP, TEXT_INPUT)
pub key_char: Option<char>, // Character for TEXT_INPUT
pub key_code: u32, // Virtual key code
// Modifier keys
pub shift: bool,
pub ctrl: bool,
pub alt: bool,
pub meta: bool, // Cmd on macOS, Win on Windows
}
}
Event Patterns
Toggle on Click
Use ToggleState for toggle buttons - it handles click transitions automatically:
#![allow(unused)]
fn main() {
use blinc_layout::stateful::stateful;
fn toggle_button(ctx: &WindowedContext) -> impl ElementBuilder {
let handle = ctx.use_state(ToggleState::Off);
stateful(handle)
.w(100.0)
.h(40.0)
.rounded(8.0)
.flex_center()
.on_state(|state, div| {
let bg = match state {
ToggleState::Off => Color::rgba(0.3, 0.3, 0.35, 1.0),
ToggleState::On => Color::rgba(0.2, 0.8, 0.4, 1.0),
};
div.set_bg(bg);
})
.on_click(|_| {
println!("Toggled!");
// ToggleState transitions automatically on click
})
.child(text("Toggle").color(Color::WHITE))
}
}
Drag to Move
#![allow(unused)]
fn main() {
use blinc_core::BlincContextState;
fn draggable_box(ctx: &WindowedContext) -> impl ElementBuilder {
let pos_x = ctx.use_signal(100.0f32);
let pos_y = ctx.use_signal(100.0f32);
let x = ctx.get(pos_x).unwrap_or(100.0);
let y = ctx.get(pos_y).unwrap_or(100.0);
div()
.absolute()
.left(x)
.top(y)
.w(80.0)
.h(80.0)
.rounded(8.0)
.bg(Color::rgba(0.4, 0.6, 1.0, 1.0))
.on_drag(move |evt| {
// Signal<T> is Copy, so it can be captured directly
// Use BlincContextState to update signals from closures
BlincContextState::get().update(pos_x, |v| v + evt.drag_delta_x);
BlincContextState::get().update(pos_y, |v| v + evt.drag_delta_y);
})
}
}
Keyboard Shortcuts
#![allow(unused)]
fn main() {
fn keyboard_handler(ctx: &WindowedContext) -> impl ElementBuilder {
div()
.w_full()
.h_full()
.on_key_down(|evt| {
// Ctrl+S or Cmd+S to save
if (evt.ctrl || evt.meta) && evt.key_code == 83 {
println!("Save triggered!");
}
// Escape to close
if evt.key_code == 27 {
println!("Escape pressed!");
}
})
}
}
Hover Preview
#![allow(unused)]
fn main() {
use blinc_layout::stateful::stateful;
fn hover_card(ctx: &WindowedContext) -> impl ElementBuilder {
let handle = ctx.use_state(ButtonState::Idle);
stateful(handle)
.w(200.0)
.h(120.0)
.rounded(12.0)
.on_state(|state, div| {
let (bg, scale) = match state {
ButtonState::Hovered => (Color::rgba(0.2, 0.2, 0.3, 1.0), 1.02),
_ => (Color::rgba(0.15, 0.15, 0.2, 1.0), 1.0),
};
div.set_bg(bg);
div.set_transform(Transform::scale(scale, scale));
})
.child(text("Hover me!").color(Color::WHITE))
}
}
Capturing State in Closures
Event handlers are Fn closures. Signal<T> is Copy, so signals can be captured directly. Use BlincContextState to access signal operations from within closures:
#![allow(unused)]
fn main() {
use blinc_core::BlincContextState;
fn counter_buttons(ctx: &WindowedContext) -> impl ElementBuilder {
let count = ctx.use_signal(0i32);
div()
.flex_row()
.gap(16.0)
.child(
div()
.on_click(move |_| {
// Signal is Copy - captured directly in the closure
BlincContextState::get().update(count, |v| v - 1);
})
.child(text("-"))
)
.child(text(&format!("{}", ctx.get(count).unwrap_or(0))))
.child(
div()
.on_click(move |_| {
BlincContextState::get().update(count, |v| v + 1);
})
.child(text("+"))
)
}
}
Thread Safety
BlincContextState is a thread-safe global singleton:
- It uses
Arc<Mutex<...>>for the reactive graph and hook state - All callbacks use
RwLockfor safe concurrent access BlincContextState::get()returns&'static BlincContextState
This makes it safe to use in event handler closures:
#![allow(unused)]
fn main() {
div()
.on_click(move |_| {
// Safe: BlincContextState is thread-safe
BlincContextState::get().update(my_signal, |v| v + 1);
BlincContextState::get().set_focus(Some("my-input"));
BlincContextState::get().request_rebuild();
})
}
For shared mutable state, use Arc<Mutex<T>>:
#![allow(unused)]
fn main() {
use std::sync::{Arc, Mutex};
fn shared_state_example() -> impl ElementBuilder {
let data = Arc::new(Mutex::new(Vec::<String>::new()));
let data_click = Arc::clone(&data);
div()
.on_click(move |_| {
data_click.lock().unwrap().push("clicked".to_string());
})
}
}
Best Practices
-
Keep handlers lightweight - Do minimal work in event handlers. For heavy operations, queue work or update state.
-
Use stateful(handle) for hover/press - Instead of manually tracking hover state, use
ctx.use_state()withstateful(handle)which handles state transitions automatically. -
Clone before closures - Clone
Arc, signals, or context references before moving them into closures. -
Avoid nested event handlers - Events bubble up, so you rarely need deeply nested handlers.
-
Use local coordinates - For hit testing within an element, use
ctx.local_xandctx.local_y.
State Management
Blinc uses Stateful elements as the primary way to manage UI state. Stateful elements handle state transitions automatically without rebuilding the entire UI tree.
Stateful Elements
Stateful is a wrapper element that manages visual states (hover, press, focus, etc.) efficiently. When state changes, only the affected element updates - not the entire UI.
Basic Usage
#![allow(unused)]
fn main() {
use blinc_layout::prelude::*;
fn feature_card(label: &str, accent: Color) -> impl ElementBuilder {
let label = label.to_string();
stateful::<ButtonState>()
.w_fit()
.p(4.0)
.rounded(14.0)
.on_state(move |ctx| {
let bg = match ctx.state() {
ButtonState::Idle => accent,
ButtonState::Hovered => Color::rgba(
(accent.r * 1.15).min(1.0),
(accent.g * 1.15).min(1.0),
(accent.b * 1.15).min(1.0),
accent.a,
),
ButtonState::Pressed => Color::rgba(
accent.r * 0.85,
accent.g * 0.85,
accent.b * 0.85,
accent.a,
),
ButtonState::Disabled => Color::GRAY,
};
div()
.bg(bg)
.on_click({
let label = label.clone();
move |_| println!("'{}' clicked!", label)
})
.child(text(&label).color(Color::WHITE))
})
}
}
How It Works
stateful::<S>()creates a StatefulBuilder for state type S.on_state(|ctx| ...)defines the callback that receives aStateContext- Events (hover, click, etc.) trigger automatic state transitions
ctx.state()returns the current state for pattern matching- Return a
Divfrom the callback - it’s merged onto the container
StateContext
The StateContext provides access to state and scoped utilities within your callback:
#![allow(unused)]
fn main() {
stateful::<ButtonState>()
.on_state(|ctx| {
// Get current state
let state = ctx.state();
// Create scoped signals (persist across rebuilds)
let counter = ctx.use_signal("counter", || 0);
// Create scoped animated values
let opacity = ctx.use_animated_value("opacity", 1.0);
// Access dependency values
let value: i32 = ctx.dep(0).unwrap_or_default();
// Dispatch events to trigger state transitions
// ctx.dispatch(CUSTOM_EVENT);
div().bg(color_for_state(state))
})
}
StateContext Methods
| Method | Description |
|---|---|
ctx.state() | Get the current state value |
ctx.event() | Get the event that triggered this callback (if any) |
ctx.use_signal(name, init) | Create/retrieve a scoped signal |
ctx.use_spring(name, target, config) | Declarative spring animation (recommended) |
ctx.spring(name, target) | Declarative spring with default stiff config |
ctx.use_animated_value(name, initial) | Low-level animated value handle |
ctx.use_timeline(name) | Create/retrieve an animated timeline |
ctx.dep::<T>(index) | Get dependency value by index |
ctx.dep_as_state::<T>(index) | Get dependency as State |
ctx.dispatch(event) | Trigger a state transition |
Event Access
Use ctx.event() to access the event that triggered the callback:
#![allow(unused)]
fn main() {
use blinc_core::events::event_types::*;
stateful::<ButtonState>()
.on_state(|ctx| {
// ctx.event() returns Some(EventContext) when triggered by user event
// Returns None when triggered by dependency changes
if let Some(event) = ctx.event() {
match event.event_type {
POINTER_UP => {
println!("Clicked at ({}, {})", event.local_x, event.local_y);
}
POINTER_ENTER => {
println!("Mouse entered!");
}
KEY_DOWN => {
if event.ctrl && event.key_code == 83 { // Ctrl+S
println!("Save shortcut pressed!");
}
}
_ => {}
}
}
let bg = match ctx.state() {
ButtonState::Idle => Color::BLUE,
ButtonState::Hovered => Color::CYAN,
ButtonState::Pressed => Color::DARK_BLUE,
_ => Color::GRAY,
};
div().bg(bg)
})
}
EventContext Fields
| Field | Type | Description |
|---|---|---|
event_type | u32 | Event type (POINTER_UP, POINTER_ENTER, etc.) |
node_id | LayoutNodeId | The node that received the event |
mouse_x, mouse_y | f32 | Absolute mouse position |
local_x, local_y | f32 | Position relative to element bounds |
bounds_x, bounds_y | f32 | Element position (top-left corner) |
bounds_width, bounds_height | f32 | Element dimensions |
scroll_delta_x, scroll_delta_y | f32 | Scroll delta (for SCROLL events) |
drag_delta_x, drag_delta_y | f32 | Drag offset (for DRAG events) |
key_char | Option<char> | Character (for TEXT_INPUT events) |
key_code | u32 | Key code (for KEY_DOWN/KEY_UP events) |
shift, ctrl, alt, meta | bool | Modifier key states |
Setting Initial State
Use .initial() to set the initial state:
#![allow(unused)]
fn main() {
stateful::<ButtonState>()
.initial(if disabled { ButtonState::Disabled } else { ButtonState::Idle })
.on_state(|ctx| {
// ...
div()
})
}
Signal Dependencies with .deps()
When a Stateful element needs to react to external signal changes (not just hover/press events), use .deps() to declare dependencies:
#![allow(unused)]
fn main() {
fn direction_toggle() -> impl ElementBuilder {
// External state that affects the element's appearance
let direction = use_state_keyed("direction", || Direction::Horizontal);
stateful::<ButtonState>()
.w(120.0)
.h(40.0)
.rounded(8.0)
// Declare dependency - on_state re-runs when this signal changes
.deps([direction.signal_id()])
.on_state(move |ctx| {
// Read the current direction value
let dir = direction.get();
let label = match dir {
Direction::Horizontal => "Horizontal",
Direction::Vertical => "Vertical",
};
let bg = match ctx.state() {
ButtonState::Idle => Color::rgba(0.3, 0.5, 0.9, 1.0),
ButtonState::Hovered => Color::rgba(0.4, 0.6, 1.0, 1.0),
_ => Color::rgba(0.3, 0.5, 0.9, 1.0),
};
div()
.bg(bg)
.on_click(move |_| {
// Toggle direction
direction.update(|d| match d {
Direction::Horizontal => Direction::Vertical,
Direction::Vertical => Direction::Horizontal,
});
})
.child(text(label).color(Color::WHITE))
})
}
}
Accessing Dependencies via StateContext
You can access dependency values directly from the context using ctx.dep():
#![allow(unused)]
fn main() {
let count_signal: State<i32> = use_state(|| 0);
let name_signal: State<String> = use_state(|| "".to_string());
stateful::<ButtonState>()
.deps([count_signal.signal_id(), name_signal.signal_id()])
.on_state(|ctx| {
// Access by index (matches order in .deps())
let count: i32 = ctx.dep(0).unwrap_or_default();
let name: String = ctx.dep(1).unwrap_or_default();
// Or get a full State<T> handle for reading and writing
if let Some(count_state) = ctx.dep_as_state::<i32>(0) {
let value = count_state.get();
// count_state.set(value + 1);
}
div().child(text(&format!("{}: {}", name, count)))
})
}
When to Use .deps()
Use .deps() when your on_state callback reads values from signals that can change independently of the element’s internal state transitions.
Without .deps(), the on_state callback only runs when:
- The element’s state changes (Idle → Hovered, etc.)
With .deps(), it also runs when:
- Any of the declared signal dependencies change
Scoped Signals
Use ctx.use_signal() for state that’s scoped to the stateful container:
#![allow(unused)]
fn main() {
stateful::<ButtonState>()
.on_state(|ctx| {
// This signal is keyed to this specific stateful container
// Format: "{stateful_key}:signal:click_count"
let click_count = ctx.use_signal("click_count", || 0);
div()
.child(text(&format!("Clicks: {}", click_count.get())))
.on_click(move |_| {
click_count.update(|n| n + 1);
})
})
}
Animated Values
Declarative API (Recommended)
Use ctx.use_spring() for declarative spring animations - specify the target and get the current animated value:
#![allow(unused)]
fn main() {
stateful::<ButtonState>()
.on_state(|ctx| {
// Declarative: specify target, get current value
let target_scale = match ctx.state() {
ButtonState::Hovered => 1.1,
_ => 1.0,
};
let current_scale = ctx.use_spring("scale", target_scale, SpringConfig::wobbly());
// For default stiff spring, use ctx.spring()
let opacity = ctx.spring("opacity", if ctx.state() == ButtonState::Idle { 0.8 } else { 1.0 });
div()
.transform(Transform::scale(current_scale, current_scale))
.opacity(opacity)
})
}
Low-Level API
For more control, use ctx.use_animated_value() which returns a SharedAnimatedValue:
#![allow(unused)]
fn main() {
stateful::<ButtonState>()
.on_state(|ctx| {
// Get the animated value handle
let scale = ctx.use_animated_value("scale", 1.0);
// With custom spring config
let opacity = ctx.use_animated_value_with_config(
"opacity",
1.0,
SpringConfig::bouncy(),
);
// Manually set target and get value
match ctx.state() {
ButtonState::Hovered => {
scale.lock().unwrap().set_target(1.1);
}
_ => {
scale.lock().unwrap().set_target(1.0);
}
}
let current_scale = scale.lock().unwrap().get();
div().transform(Transform::scale(current_scale, current_scale))
})
}
Animated Timelines
Use ctx.use_timeline() for complex multi-property animations with keyframes:
#![allow(unused)]
fn main() {
stateful::<ButtonState>()
.on_state(|ctx| {
// Persisted timeline scoped to this stateful
let timeline = ctx.use_timeline("pulse");
// Configure on first use, get existing entry IDs on subsequent calls
let opacity_id = timeline.lock().unwrap().configure(|t| {
let id = t.add(0, 1000, 0.5, 1.0); // 0ms offset, 1000ms duration
t.set_loop(-1); // Loop forever
t.start();
id
});
let opacity = timeline.lock().unwrap().get(opacity_id);
div().opacity(opacity)
})
}
The configure() method is idempotent - it only runs the configuration closure on the first call and returns existing entry IDs on subsequent calls.
Built-in State Types
Blinc provides common state types with automatic transitions:
ButtonState
#![allow(unused)]
fn main() {
ButtonState::Idle // Default state
ButtonState::Hovered // Mouse over element
ButtonState::Pressed // Mouse button down
ButtonState::Disabled // Non-interactive
}
Transitions:
Idle→Hovered(on pointer enter)Hovered→Idle(on pointer leave)Hovered→Pressed(on pointer down)Pressed→Hovered(on pointer up)
NoState
For containers that only need dependency tracking without state transitions:
#![allow(unused)]
fn main() {
stateful::<NoState>()
.deps([some_signal.signal_id()])
.on_state(|_ctx| {
// Rebuilds when dependencies change
div().child(text("Content"))
})
}
Custom State Types
Define your own state enum for complex interactions:
#![allow(unused)]
fn main() {
use blinc_layout::stateful::StateTransitions;
use blinc_core::events::event_types::*;
#[derive(Clone, Copy, PartialEq, Eq, Hash, Default)]
enum DragState {
#[default]
Idle,
Hovering,
Dragging,
}
impl StateTransitions for DragState {
fn on_event(&self, event: u32) -> Option<Self> {
match (self, event) {
(DragState::Idle, POINTER_ENTER) => Some(DragState::Hovering),
(DragState::Hovering, POINTER_LEAVE) => Some(DragState::Idle),
(DragState::Hovering, POINTER_DOWN) => Some(DragState::Dragging),
(DragState::Dragging, POINTER_UP) => Some(DragState::Idle),
_ => None,
}
}
}
fn draggable_item() -> impl ElementBuilder {
stateful::<DragState>()
.w(100.0)
.h(100.0)
.rounded(8.0)
.on_state(|ctx| {
let bg = match ctx.state() {
DragState::Idle => Color::BLUE,
DragState::Hovering => Color::CYAN,
DragState::Dragging => Color::GREEN,
};
div().bg(bg)
})
}
}
Keyed State (Global Signals)
For state persisted across UI rebuilds with a string key:
#![allow(unused)]
fn main() {
let is_expanded = use_state_keyed("sidebar_expanded", || false);
// Read
let expanded = is_expanded.get();
// Update
is_expanded.set(true);
is_expanded.update(|v| !v);
// Get signal ID for use with .deps()
let signal_id = is_expanded.signal_id();
}
Best Practices
-
Use
stateful::<S>()builder - This is the primary pattern for stateful UI elements. -
Return Div from callbacks - The new API expects you to return a Div, not mutate a container.
-
Use
.initial()for non-default states - Set initial state explicitly when needed. -
Use
ctx.use_signal()for local state - Scoped signals are automatically keyed. -
Use
ctx.dep()for dependency access - Cleaner than capturing signals in closures. -
Prefer built-in state types - They have correct transitions already defined.
-
Custom states for complex flows - Define your own when built-in types don’t fit.
-
Use
.deps()for external dependencies - Whenon_stateneeds to react to signal changes.
Spring Physics
Blinc uses spring physics for natural, responsive animations. Springs provide smooth motion that feels organic compared to fixed-duration easing.
SpringConfig
All spring animations are configured with SpringConfig:
#![allow(unused)]
fn main() {
use blinc_animation::SpringConfig;
// Custom spring
let config = SpringConfig {
stiffness: 180.0, // How "tight" the spring is
damping: 12.0, // How quickly oscillation settles
mass: 1.0, // Virtual mass of the object
..Default::default()
};
}
Presets
Blinc provides common spring presets:
#![allow(unused)]
fn main() {
SpringConfig::stiff() // Fast, minimal overshoot (stiffness: 400, damping: 30)
SpringConfig::snappy() // Quick with slight bounce (stiffness: 300, damping: 20)
SpringConfig::gentle() // Soft, slower motion (stiffness: 120, damping: 14)
SpringConfig::wobbly() // Bouncy, playful (stiffness: 180, damping: 12)
}
Choosing a Spring
| Use Case | Preset | Feel |
|---|---|---|
| Button press feedback | stiff() | Immediate, snappy |
| Menu/panel transitions | snappy() | Quick with character |
| Drag release | gentle() | Smooth, natural |
| Playful interactions | wobbly() | Fun, bouncy |
AnimatedValue
AnimatedValue wraps a single f32 value with spring physics:
Creating AnimatedValues
#![allow(unused)]
fn main() {
fn my_component(ctx: &WindowedContext) -> impl ElementBuilder {
// Create a persisted animated value
let scale = ctx.use_animated_value(1.0, SpringConfig::snappy());
// With a custom key
let x_pos = ctx.use_animated_value_for("card_x", 0.0, SpringConfig::gentle());
// ...
}
}
Reading Values
#![allow(unused)]
fn main() {
// Get current animated value
let current = scale.lock().unwrap().get();
// Use in transforms
div().scale(current)
}
Setting Targets
#![allow(unused)]
fn main() {
// Animate to new target
scale.lock().unwrap().set_target(1.2);
// Immediate set (no animation)
scale.lock().unwrap().set(1.0);
}
Example: Hover Scale with Spring Animation
For smooth spring-animated hover effects, use motion() with animated values:
#![allow(unused)]
fn main() {
use std::sync::Arc;
use blinc_layout::motion::motion;
fn hover_scale_card(ctx: &WindowedContext) -> impl ElementBuilder {
let scale = ctx.use_animated_value(1.0, SpringConfig::snappy());
let hover_scale = Arc::clone(&scale);
let leave_scale = Arc::clone(&scale);
// motion() is a container - apply transforms to it, style the child
motion()
.scale(scale.lock().unwrap().get())
.on_hover_enter(move |_| {
hover_scale.lock().unwrap().set_target(1.05);
})
.on_hover_leave(move |_| {
leave_scale.lock().unwrap().set_target(1.0);
})
.child(
div()
.w(200.0)
.h(120.0)
.rounded(12.0)
.bg(Color::rgba(0.2, 0.2, 0.3, 1.0))
.flex_center()
.child(text("Hover me").color(Color::WHITE))
)
}
}
Note: For simple hover state changes without spring physics (e.g., just color changes), prefer stateful(handle) which is more efficient. Use motion() when you specifically need spring-animated values.
Example: Drag Position
Use motion() for elements with animated position:
#![allow(unused)]
fn main() {
use blinc_layout::motion::motion;
fn draggable_element(ctx: &WindowedContext) -> impl ElementBuilder {
let x = ctx.use_animated_value(100.0, SpringConfig::wobbly());
let y = ctx.use_animated_value(100.0, SpringConfig::wobbly());
let drag_x = Arc::clone(&x);
let drag_y = Arc::clone(&y);
// motion() handles the animated position, child has the styling
motion()
.absolute()
.left(x.lock().unwrap().get())
.top(y.lock().unwrap().get())
.on_drag(move |evt| {
let mut x = drag_x.lock().unwrap();
let mut y = drag_y.lock().unwrap();
x.set_target(x.target() + evt.drag_delta_x);
y.set_target(y.target() + evt.drag_delta_y);
})
.child(
div()
.w(80.0)
.h(80.0)
.rounded(8.0)
.bg(Color::rgba(0.4, 0.6, 1.0, 1.0))
)
}
}
Motion Containers
For declarative enter/exit animations, use motion():
#![allow(unused)]
fn main() {
use blinc_layout::motion::motion;
motion()
.fade_in(300) // Fade in over 300ms
.child(my_content())
motion()
.scale_in(300) // Scale from 0 to 1
.child(my_content())
motion()
.slide_in(SlideDirection::Left, 300)
.child(my_content())
}
See Motion Containers for full details.
Best Practices
-
Match spring to interaction - Use stiffer springs for immediate feedback, gentler for ambient motion.
-
Persist animated values - Use
ctx.use_animated_value()so animations survive UI rebuilds. -
Clone Arc before closures - Always
Arc::clone()before moving into event handlers. -
Don’t fight the spring - Let animations complete naturally. Interrupting with new targets is fine.
-
Use BlincComponent - For complex components with multiple animations, use the derive macro for type-safe hooks.
Keyframe Timelines
For time-based animations with precise control, use AnimatedTimeline. Timelines support multiple animation entries, looping, alternate (ping-pong) mode, and coordinated playback.
Creating Timelines
In WindowedContext
#![allow(unused)]
fn main() {
fn my_component(ctx: &WindowedContext) -> impl ElementBuilder {
// Create a persisted timeline
let timeline = ctx.use_animated_timeline();
// With a custom key
let loader_timeline = ctx.use_animated_timeline_for("loader");
// ...
}
}
In StateContext (Stateful Elements)
#![allow(unused)]
fn main() {
stateful::<ButtonState>().on_state(|ctx| {
// use_timeline returns (entry_ids, TimelineHandle)
let ((entry1, entry2), timeline) = ctx.use_timeline("fade", |t| {
let e1 = t.add(0, 500, 0.0, 1.0);
let e2 = t.add(250, 500, 0.0, 100.0);
t.set_loop(-1);
t.start();
(e1, e2)
});
let value1 = timeline.get(entry1).unwrap_or(0.0);
let value2 = timeline.get(entry2).unwrap_or(0.0);
div()
})
}
Configuring Timelines
Use the configure() method to set up animations once:
#![allow(unused)]
fn main() {
let timeline = ctx.use_animated_timeline();
// Configure returns entry IDs for accessing values later
let entry_id = timeline.lock().unwrap().configure(|t| {
let id = t.add(0, 1000, 0.0, 360.0); // 0ms start, 1000ms duration, 0° to 360°
t.set_loop(-1); // Loop forever (-1 = infinite)
t.start();
id
});
}
The closure only runs on first call. Subsequent calls return existing entry IDs.
Adding Animations
Basic Entry
#![allow(unused)]
fn main() {
timeline.lock().unwrap().configure(|t| {
// add(offset_ms, duration_ms, start_value, end_value)
let rotation = t.add(0, 1000, 0.0, 360.0);
let scale = t.add(0, 500, 1.0, 1.5); // Same start, shorter duration
let opacity = t.add(500, 500, 1.0, 0.0); // Starts at 500ms
(rotation, scale, opacity) // Return tuple of IDs
});
}
With Easing
#![allow(unused)]
fn main() {
use blinc_animation::Easing;
timeline.lock().unwrap().configure(|t| {
// add_with_easing(offset_ms, duration_ms, start, end, easing)
let smooth = t.add_with_easing(0, 500, 0.0, 60.0, Easing::EaseInOut);
let bouncy = t.add_with_easing(0, 500, 0.0, 1.0, Easing::EaseOutQuad);
(smooth, bouncy)
});
}
Using StaggerBuilder
For multiple entries with automatic offset calculation:
#![allow(unused)]
fn main() {
timeline.lock().unwrap().configure(|t| {
// stagger(base_offset, stagger_amount)
let mut stagger = t.stagger(0, 100); // 0ms base, 100ms between each
let bar1 = stagger.add(500, 0.0, 60.0); // offset: 0ms
let bar2 = stagger.add(500, 0.0, 60.0); // offset: 100ms
let bar3 = stagger.add(500, 0.0, 60.0); // offset: 200ms
// With easing
let bar4 = stagger.add_with_easing(500, 0.0, 60.0, Easing::EaseInOut);
(bar1, bar2, bar3, bar4)
});
}
Reading Values
#![allow(unused)]
fn main() {
let value = timeline.lock().unwrap().get(entry_id).unwrap_or(0.0);
// Get entry progress (0.0 to 1.0)
let progress = timeline.lock().unwrap().entry_progress(entry_id);
// Get overall timeline progress
let total_progress = timeline.lock().unwrap().progress();
}
Playback Control
#![allow(unused)]
fn main() {
let mut t = timeline.lock().unwrap();
t.start(); // Start playing
t.pause(); // Pause (can resume)
t.resume(); // Resume from pause
t.stop(); // Stop and reset
t.restart(); // Start from beginning
t.reverse(); // Toggle playback direction
t.seek(500.0); // Jump to 500ms position
t.set_loop(3); // Loop 3 times
t.set_loop(-1); // Loop forever
t.set_alternate(true); // Ping-pong mode
t.set_playback_rate(2.0); // 2x speed
t.is_playing(); // Check if playing
t.progress(); // Overall progress (0.0 to 1.0)
}
Alternate (Ping-Pong) Mode
Enable alternate mode for back-and-forth animations that maintain stagger across loops:
#![allow(unused)]
fn main() {
let ((bar1, bar2, bar3), timeline) = ctx.use_timeline("bars", |t| {
// Three staggered entries
let b1 = t.add_with_easing(0, 500, 0.0, 60.0, Easing::EaseInOut);
let b2 = t.add_with_easing(100, 500, 0.0, 60.0, Easing::EaseInOut);
let b3 = t.add_with_easing(200, 500, 0.0, 60.0, Easing::EaseInOut);
t.set_alternate(true); // Reverse on each loop
t.set_loop(-1); // Loop forever
t.start();
(b1, b2, b3)
});
}
With alternate mode:
- Timeline plays forward (0 → duration)
- On completion, reverses direction (duration → 0)
- Stagger offsets maintain their relative timing
- No jump back to start - smooth continuous motion
Example: Staggered Wave Animation
#![allow(unused)]
fn main() {
fn sliding_bars() -> impl ElementBuilder {
stateful::<NoState>().on_state(|ctx| {
let ((bar1_id, bar2_id, bar3_id), timeline) = ctx.use_timeline("bars", |t| {
// Staggered entries with easing
let bar1 = t.add_with_easing(0, 500, 0.0, 60.0, Easing::EaseInOut);
let bar2 = t.add_with_easing(100, 500, 0.0, 60.0, Easing::EaseInOut);
let bar3 = t.add_with_easing(200, 500, 0.0, 60.0, Easing::EaseInOut);
t.set_alternate(true);
t.set_loop(-1);
t.start();
(bar1, bar2, bar3)
});
let bar1_x = timeline.get(bar1_id).unwrap_or(0.0);
let bar2_x = timeline.get(bar2_id).unwrap_or(0.0);
let bar3_x = timeline.get(bar3_id).unwrap_or(0.0);
div()
.flex_col()
.gap(12.0)
.child(div().w(30.0).h(12.0).bg(Color::GREEN)
.transform(Transform::translate(bar1_x, 0.0)))
.child(div().w(30.0).h(12.0).bg(Color::YELLOW)
.transform(Transform::translate(bar2_x, 0.0)))
.child(div().w(30.0).h(12.0).bg(Color::RED)
.transform(Transform::translate(bar3_x, 0.0)))
})
}
}
Example: Spinning Loader
#![allow(unused)]
fn main() {
use std::f32::consts::PI;
fn spinning_loader(ctx: &WindowedContext) -> impl ElementBuilder {
let timeline = ctx.use_animated_timeline();
let entry_id = timeline.lock().unwrap().configure(|t| {
let id = t.add(0, 1000, 0.0, 360.0);
t.set_loop(-1);
t.start();
id
});
let render_timeline = Arc::clone(&timeline);
canvas(move |draw_ctx, bounds| {
let angle_deg = render_timeline.lock().unwrap().get(entry_id).unwrap_or(0.0);
let angle_rad = angle_deg * PI / 180.0;
let cx = bounds.width / 2.0;
let cy = bounds.height / 2.0;
let radius = 30.0;
// Draw spinning arc
// ... drawing code
})
.w(80.0)
.h(80.0)
}
}
Example: Pulsing Ring
#![allow(unused)]
fn main() {
fn pulsing_ring() -> impl ElementBuilder {
stateful::<ButtonState>().on_state(|ctx| {
let is_running = ctx.use_signal("running", || false);
// Keyframe animations with ping-pong
let scale = ctx.use_keyframes("scale", |k| {
k.at(0, 0.8)
.at(800, 1.2)
.ease(Easing::EaseInOut)
.ping_pong()
.loop_infinite()
});
let opacity = ctx.use_keyframes("opacity", |k| {
k.at(0, 0.4)
.at(800, 1.0)
.ease(Easing::EaseInOut)
.ping_pong()
.loop_infinite()
});
// Toggle on click
if let Some(event) = ctx.event() {
if event.event_type == POINTER_UP {
if is_running.get() {
scale.stop();
opacity.stop();
is_running.set(false);
} else {
scale.start();
opacity.start();
is_running.set(true);
}
}
}
let s = scale.get();
let o = opacity.get();
div()
.w(60.0).h(60.0)
.border(4.0, Color::rgba(1.0, 0.5, 0.3, o))
.rounded(30.0)
.transform(Transform::scale(s, s))
})
}
}
Example: Progress Bar
#![allow(unused)]
fn main() {
fn animated_progress(ctx: &WindowedContext) -> impl ElementBuilder {
let timeline = ctx.use_animated_timeline();
let entry_id = timeline.lock().unwrap().configure(|t| {
let id = t.add(0, 2000, 0.0, 1.0); // 2 second fill
t.start();
id
});
let click_timeline = Arc::clone(&timeline);
let render_timeline = Arc::clone(&timeline);
div()
.w(200.0)
.h(20.0)
.rounded(10.0)
.bg(Color::rgba(0.2, 0.2, 0.25, 1.0))
.on_click(move |_| {
// Restart on click
let mut t = click_timeline.lock().unwrap();
t.stop();
t.start();
})
.child(
canvas(move |draw_ctx, bounds| {
let progress = render_timeline.lock().unwrap()
.get(entry_id)
.unwrap_or(0.0);
let fill_width = bounds.width * progress;
draw_ctx.fill_rect(
Rect::new(0.0, 0.0, fill_width, bounds.height),
CornerRadius::uniform(10.0),
Brush::Solid(Color::rgba(0.4, 0.6, 1.0, 1.0)),
);
})
.w_full()
.h_full()
)
}
}
ConfigureResult Types
The configure() method supports various return types:
#![allow(unused)]
fn main() {
// Single entry
let id: TimelineEntryId = t.configure(|t| t.add(...));
// Tuple of entries
let (a, b): (TimelineEntryId, TimelineEntryId) = t.configure(|t| {
(t.add(...), t.add(...))
});
// Triple
let (a, b, c) = t.configure(|t| {
(t.add(...), t.add(...), t.add(...))
});
// Vec for dynamic counts
let ids: Vec<TimelineEntryId> = t.configure(|t| {
(0..5).map(|i| t.add(i * 100, 500, 0.0, 1.0)).collect()
});
}
Available Easing Functions
#![allow(unused)]
fn main() {
use blinc_animation::Easing;
Easing::Linear // No easing
Easing::EaseIn // Slow start (cubic)
Easing::EaseOut // Slow end (cubic)
Easing::EaseInOut // Slow start and end (cubic)
Easing::EaseInQuad // Quadratic ease in
Easing::EaseOutQuad // Quadratic ease out
Easing::EaseInOutQuad // Quadratic ease in-out
Easing::EaseInCubic // Cubic ease in
Easing::EaseOutCubic // Cubic ease out
Easing::EaseInOutCubic // Cubic ease in-out
Easing::EaseInQuart // Quartic ease in
Easing::EaseOutQuart // Quartic ease out
Easing::EaseInOutQuart // Quartic ease in-out
Easing::CubicBezier(x1, y1, x2, y2) // Custom bezier curve
}
Timeline vs Spring
| Feature | Timeline | Spring |
|---|---|---|
| Duration | Fixed | Physics-based |
| Looping | Built-in | Manual |
| Multiple values | Single timeline | Individual values |
| Ping-pong | set_alternate(true) | Manual reverse |
| Interruption | Restart needed | Natural blend |
| Use case | Continuous loops, sequences | Interactive, responsive |
Use timelines for:
- Loading spinners
- Background animations
- Sequenced animations
- Staggered wave effects
- Precise timing control
Use springs for:
- User interactions
- Drag and drop
- Hover effects
- Natural motion
Motion Containers
The motion() element provides declarative enter/exit animations for content. It’s ideal for animated lists, page transitions, and conditional rendering.
Basic Usage
#![allow(unused)]
fn main() {
use blinc_layout::motion::motion;
motion()
.fade_in(300) // Duration in milliseconds
.child(my_content())
}
Animation Presets
Fade
#![allow(unused)]
fn main() {
motion()
.fade_in(300)
.fade_out(200)
.child(content)
}
Scale
#![allow(unused)]
fn main() {
motion()
.scale_in(300) // Scale from 0 to 1
.scale_out(200) // Scale from 1 to 0
.child(content)
}
Slide
#![allow(unused)]
fn main() {
use blinc_layout::motion::SlideDirection;
motion()
.slide_in(SlideDirection::Left, 300)
.slide_out(SlideDirection::Right, 200)
.child(content)
// Available directions:
// SlideDirection::Top
// SlideDirection::Bottom
// SlideDirection::Left
// SlideDirection::Right
}
Bounce
#![allow(unused)]
fn main() {
motion()
.bounce_in(400) // Bouncy entrance
.bounce_out(200)
.child(content)
}
Pop
#![allow(unused)]
fn main() {
motion()
.pop_in(300) // Scale with overshoot
.pop_out(200)
.child(content)
}
Combining Animations
Apply multiple effects:
#![allow(unused)]
fn main() {
motion()
.fade_in(300)
.scale_in(300)
.child(content)
}
Staggered Lists
Animate list items with delays between each:
#![allow(unused)]
fn main() {
use blinc_layout::motion::{motion, StaggerConfig, AnimationPreset};
let items = vec!["Item 1", "Item 2", "Item 3", "Item 4"];
motion()
.stagger(
StaggerConfig::new(100, AnimationPreset::fade_in(300))
)
.children(
items.iter().map(|item| {
div()
.p(12.0)
.bg(Color::rgba(0.2, 0.2, 0.25, 1.0))
.child(text(*item).color(Color::WHITE))
})
)
}
Stagger Configuration
#![allow(unused)]
fn main() {
StaggerConfig::new(delay_ms, preset)
.reverse() // Animate last to first
.from_center() // Animate from center outward
.limit(10) // Only stagger first N items
}
Stagger Directions
#![allow(unused)]
fn main() {
// Forward (default): 0, 1, 2, 3, 4...
StaggerConfig::new(100, preset)
// Reverse: 4, 3, 2, 1, 0...
StaggerConfig::new(100, preset).reverse()
// From center: 2, 1/3, 0/4 (for 5 items)
StaggerConfig::new(100, preset).from_center()
}
Binding to AnimatedValue
For continuous animation control, bind motion transforms to AnimatedValue:
#![allow(unused)]
fn main() {
fn pull_to_refresh(ctx: &WindowedContext) -> impl ElementBuilder {
let offset_y = ctx.use_animated_value(0.0, SpringConfig::wobbly());
let icon_scale = ctx.use_animated_value(0.5, SpringConfig::snappy());
let icon_opacity = ctx.use_animated_value(0.0, SpringConfig::snappy());
stack()
// Refresh icon (behind content)
.child(
motion()
.scale(icon_scale.clone())
.opacity(icon_opacity.clone())
.child(refresh_icon())
)
// Content (translates down to reveal icon)
.child(
motion()
.translate_y(offset_y.clone())
.child(content_list())
)
}
}
Example: Animated Card List
Use a stateful element with .deps() to react to visibility state changes:
#![allow(unused)]
fn main() {
fn animated_card_list(ctx: &WindowedContext) -> impl ElementBuilder {
let show_cards = ctx.use_state_keyed("show_cards", || true);
let button_handle = ctx.use_state(ButtonState::Idle);
stateful(button_handle)
.flex_col()
.gap(16.0)
.deps(&[show_cards.signal_id()])
.on_state(move |state, container| {
let visible = show_cards.get();
let label = if visible { "Hide Cards" } else { "Show Cards" };
let bg = match state {
ButtonState::Idle => Color::rgba(0.3, 0.5, 0.9, 1.0),
ButtonState::Hovered => Color::rgba(0.4, 0.6, 1.0, 1.0),
_ => Color::rgba(0.3, 0.5, 0.9, 1.0),
};
// Build content based on visibility
let mut content = div()
.bg(bg)
.px(16.0)
.py(8.0)
.rounded(8.0)
.child(text(label).color(Color::WHITE));
container.merge(content);
})
.on_click(move |_| {
show_cards.update(|v| !v);
})
.child(card_list(ctx))
}
fn card_list(ctx: &WindowedContext) -> impl ElementBuilder {
// Cards with staggered animation
motion()
.stagger(StaggerConfig::new(80, AnimationPreset::fade_in(300)))
.children(
(0..5).map(|i| {
div()
.w(300.0)
.p(16.0)
.rounded(12.0)
.bg(Color::rgba(0.15, 0.15, 0.2, 1.0))
.child(text(&format!("Card {}", i + 1)).color(Color::WHITE))
})
)
}
}
Example: Page Transition
Use a custom state type for page navigation:
#![allow(unused)]
fn main() {
use blinc_layout::stateful::{stateful, StateTransitions};
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
enum Page {
Home,
Settings,
Profile,
}
// Pages don't auto-transition - we change them programmatically
impl StateTransitions for Page {
fn on_event(&self, _event: u32) -> Option<Self> {
None // No automatic transitions
}
}
fn page_transition(ctx: &WindowedContext) -> impl ElementBuilder {
let page_handle = ctx.use_state(Page::Home);
stateful(page_handle.clone())
.w_full()
.h_full()
.on_state(move |page, container| {
// Render different content based on current page
let content = match page {
Page::Home => div().child(text("Home Page").color(Color::WHITE)),
Page::Settings => div().child(text("Settings Page").color(Color::WHITE)),
Page::Profile => div().child(text("Profile Page").color(Color::WHITE)),
};
container.merge(
div()
.child(
motion()
.fade_in(200)
.slide_in(SlideDirection::Right, 200)
.child(content)
)
);
})
}
// Navigate programmatically
fn nav_button(ctx: &WindowedContext, target: Page, label: &str) -> impl ElementBuilder {
let page_handle = ctx.use_state(Page::Home); // Same handle
let handle = ctx.use_state_for(label, ButtonState::Idle);
stateful(handle)
.px(16.0)
.py(8.0)
.rounded(8.0)
.on_state(|state, div| {
let bg = match state {
ButtonState::Idle => Color::rgba(0.3, 0.5, 0.9, 1.0),
ButtonState::Hovered => Color::rgba(0.4, 0.6, 1.0, 1.0),
_ => Color::rgba(0.3, 0.5, 0.9, 1.0),
};
div.set_bg(bg);
})
.on_click(move |_| {
page_handle.set(target);
})
.child(text(label).color(Color::WHITE))
}
}
Motion vs Manual Animation
| Feature | Motion | AnimatedValue |
|---|---|---|
| Setup | Declarative | Imperative |
| Control | Preset-based | Full control |
| Enter/Exit | Built-in | Manual |
| Lists | Stagger support | Manual delays |
| Use case | Transitions | Interactive |
Use motion for:
- List item animations
- Page transitions
- Conditional content
- Staggered reveals
Use AnimatedValue for:
- Drag interactions
- Hover effects
- Custom physics
- Continuous binding
BlincComponent Macro
The BlincComponent derive macro generates type-safe hooks for state and animations, eliminating manual string keys and reducing boilerplate. Use it to define component-scoped state that persists across UI rebuilds.
Overview
BlincComponent is designed for two primary use cases:
- State Management - Generate
State<T>hooks for component data (counters, toggles, form values) - Animations - Generate
SharedAnimatedValuehooks for spring-based animations
Basic Usage
#![allow(unused)]
fn main() {
use blinc_app::prelude::*;
#[derive(BlincComponent)]
struct MyComponent;
}
This generates:
MyComponent::COMPONENT_KEY- Unique compile-time keyMyComponent::use_animated_value(ctx, initial, config)- Spring animationMyComponent::use_animated_value_with(ctx, suffix, initial, config)- Named springMyComponent::use_animated_timeline(ctx)- Keyframe timelineMyComponent::use_animated_timeline_with(ctx, suffix)- Named timeline
State Fields
Fields without #[animation] generate state hooks:
#![allow(unused)]
fn main() {
#[derive(BlincComponent)]
struct Counter {
count: i32, // Generates: use_count(ctx, initial) -> State<i32>
step: i32, // Generates: use_step(ctx, initial) -> State<i32>
}
}
Using State Fields
#![allow(unused)]
fn main() {
fn counter_demo(ctx: &WindowedContext) -> impl ElementBuilder {
// BlincComponent generates type-safe state hooks
let count = Counter::use_count(ctx, 0);
let step = Counter::use_step(ctx, 1);
// Create persistent button state handle
let button_handle = ctx.use_state(ButtonState::Idle);
// Use stateful(handle) with .deps() to react to state changes
stateful(button_handle)
.flex_col()
.gap(16.0)
.p(16.0)
.deps(&[count.signal_id(), step.signal_id()])
.on_state(move |state, container| {
// Read current values inside on_state
let current_count = count.get();
let current_step = step.get();
let bg = match state {
ButtonState::Idle => Color::rgba(0.15, 0.15, 0.2, 1.0),
ButtonState::Hovered => Color::rgba(0.18, 0.18, 0.25, 1.0),
_ => Color::rgba(0.15, 0.15, 0.2, 1.0),
};
// Update container with dynamic content
container.merge(
div()
.bg(bg)
.child(text(&format!("Count: {}", current_count)).color(Color::WHITE))
.child(text(&format!("Step: {}", current_step)).color(Color::WHITE))
);
})
.on_click(move |_| {
let current_step = step.get();
count.update(|v| v + current_step);
})
.child(increment_button(ctx))
}
fn increment_button(ctx: &WindowedContext) -> impl ElementBuilder {
let handle = ctx.use_state(ButtonState::Idle);
stateful(handle)
.px(16.0)
.py(8.0)
.rounded(8.0)
.on_state(|state, div| {
let bg = match state {
ButtonState::Idle => Color::rgba(0.3, 0.5, 0.9, 1.0),
ButtonState::Hovered => Color::rgba(0.4, 0.6, 1.0, 1.0),
ButtonState::Pressed => Color::rgba(0.2, 0.4, 0.8, 1.0),
_ => Color::rgba(0.3, 0.5, 0.9, 1.0),
};
div.set_bg(bg);
})
.child(text("Increment").color(Color::WHITE))
}
}
Key point: When UI content depends on state values that can change, use stateful(handle) with .deps() to declare the dependency. The on_state callback re-runs whenever those signals change, and you update the display via container.merge() or div.set_*() methods.
Common State Patterns
#![allow(unused)]
fn main() {
#[derive(BlincComponent)]
struct TodoList {
items: Vec<String>, // List of items
filter: Filter, // Current filter mode
selected_index: Option<usize>, // Currently selected item
}
#[derive(BlincComponent)]
struct FormData {
username: String,
email: String,
is_valid: bool,
}
#[derive(BlincComponent)]
struct Settings {
theme: Theme,
notifications_enabled: bool,
volume: f32,
}
}
Animation Fields
Fields with #[animation] generate spring animation hooks:
#![allow(unused)]
fn main() {
#[derive(BlincComponent)]
struct PullToRefresh {
#[animation]
content_offset: f32, // Generates: use_content_offset(ctx, initial, config)
#[animation]
icon_scale: f32, // Generates: use_icon_scale(ctx, initial, config)
#[animation]
icon_opacity: f32, // Generates: use_icon_opacity(ctx, initial, config)
}
}
Using Animation Fields
#![allow(unused)]
fn main() {
fn pull_to_refresh_demo(ctx: &WindowedContext) -> impl ElementBuilder {
// Each field gets its own type-safe hook
let content_offset = PullToRefresh::use_content_offset(ctx, 0.0, SpringConfig::wobbly());
let icon_scale = PullToRefresh::use_icon_scale(ctx, 0.5, SpringConfig::snappy());
let icon_opacity = PullToRefresh::use_icon_opacity(ctx, 0.0, SpringConfig::snappy());
// Use with motion() for animated rendering
motion()
.translate_y(content_offset.lock().unwrap().get())
.child(/* content */)
}
}
Combining State and Animation
A component can have both state and animation fields:
#![allow(unused)]
fn main() {
#[derive(BlincComponent)]
struct ExpandableCard {
// State fields
is_expanded: bool,
content: String,
// Animation fields
#[animation]
height: f32,
#[animation]
arrow_rotation: f32,
}
fn expandable_card(ctx: &WindowedContext) -> impl ElementBuilder {
let is_expanded = ExpandableCard::use_is_expanded(ctx, false);
let height = ExpandableCard::use_height(ctx, 60.0, SpringConfig::snappy());
let arrow_rotation = ExpandableCard::use_arrow_rotation(ctx, 0.0, SpringConfig::snappy());
let expanded = is_expanded.get();
motion()
.h(height.lock().unwrap().get())
.on_click(move |_| {
is_expanded.update(|v| !v);
let target_height = if !expanded { 200.0 } else { 60.0 };
let target_rotation = if !expanded { 180.0 } else { 0.0 };
height.lock().unwrap().set_target(target_height);
arrow_rotation.lock().unwrap().set_target(target_rotation);
})
.child(/* card content */)
}
}
Multiple Values per Component
Use _with suffix methods for multiple values of the same type:
#![allow(unused)]
fn main() {
#[derive(BlincComponent)]
struct DraggableBox;
fn draggable(ctx: &WindowedContext) -> impl ElementBuilder {
// Multiple animated values with suffixes
let x = DraggableBox::use_animated_value_with(ctx, "x", 100.0, SpringConfig::wobbly());
let y = DraggableBox::use_animated_value_with(ctx, "y", 100.0, SpringConfig::wobbly());
// ...
}
}
Timelines with BlincComponent
#![allow(unused)]
fn main() {
#[derive(BlincComponent)]
struct SpinningLoader;
fn loader(ctx: &WindowedContext) -> impl ElementBuilder {
let timeline = SpinningLoader::use_animated_timeline(ctx);
let entry_id = timeline.lock().unwrap().configure(|t| {
let id = t.add(0, 1000, 0.0, 360.0);
t.set_loop(-1);
t.start();
id
});
// ...
}
}
How It Works
The macro generates a unique key from module_path!() and the struct name:
#![allow(unused)]
fn main() {
impl MyCard {
pub const COMPONENT_KEY: &'static str = concat!(module_path!(), "::", stringify!(MyCard));
// e.g., "my_app::components::MyCard"
}
}
This ensures:
- Uniqueness - Keys are unique across your entire codebase
- Stability - Keys don’t change unless you move/rename the struct
- No collisions - Different modules can have same-named components
Generated Methods
For Unit Structs
#![allow(unused)]
fn main() {
#[derive(BlincComponent)]
struct MyComponent;
// Generates:
impl MyComponent {
pub const COMPONENT_KEY: &'static str;
pub fn use_animated_value(
ctx: &WindowedContext,
initial: f32,
config: SpringConfig,
) -> SharedAnimatedValue;
pub fn use_animated_value_with(
ctx: &WindowedContext,
suffix: &str,
initial: f32,
config: SpringConfig,
) -> SharedAnimatedValue;
pub fn use_animated_timeline(
ctx: &WindowedContext,
) -> SharedAnimatedTimeline;
pub fn use_animated_timeline_with(
ctx: &WindowedContext,
suffix: &str,
) -> SharedAnimatedTimeline;
}
}
For Structs with Fields
#![allow(unused)]
fn main() {
#[derive(BlincComponent)]
struct MyComponent {
#[animation]
scale: f32,
count: i32,
}
// Additionally generates:
impl MyComponent {
pub fn use_scale(
ctx: &WindowedContext,
initial: f32,
config: SpringConfig,
) -> SharedAnimatedValue;
pub fn use_count(
ctx: &WindowedContext,
initial: i32,
) -> State<i32>;
}
}
Best Practices
-
Group related state and animations - A component should represent one logical UI element with its related state and animations.
-
Use fields for named values - Prefer
#[animation] scale: f32overuse_animated_value_with(ctx, "scale", ...). -
Combine state and animations - Use state fields for data, animation fields for visual transitions.
-
Document fields - Add doc comments to fields for generated method documentation.
#![allow(unused)]
fn main() {
#[derive(BlincComponent)]
struct ExpandableSection {
/// Whether the section is currently expanded
is_expanded: bool,
/// Animated height for smooth expand/collapse
#[animation]
height: f32,
}
}
- Use
motion()with animated values - Wrap content using animated values inmotion()for proper redraws.
Building Reusable Components
This guide covers patterns for creating composable, reusable UI components in Blinc.
Component Patterns
Simple Function Components
The simplest pattern - a function returning an element:
#![allow(unused)]
fn main() {
fn card(title: &str) -> Div {
div()
.p(16.0)
.rounded(12.0)
.bg(Color::rgba(0.15, 0.15, 0.2, 1.0))
.child(
text(title)
.size(18.0)
.weight(FontWeight::SemiBold)
.color(Color::WHITE)
)
}
// Usage
div().child(card("My Card"))
}
Components with Children
Accept generic children with impl ElementBuilder:
#![allow(unused)]
fn main() {
fn card_with_content<E: ElementBuilder>(title: &str, content: E) -> Div {
div()
.p(16.0)
.rounded(12.0)
.bg(Color::rgba(0.15, 0.15, 0.2, 1.0))
.flex_col()
.gap(12.0)
.child(
text(title)
.size(18.0)
.weight(FontWeight::SemiBold)
.color(Color::WHITE)
)
.child(content)
}
// Usage
card_with_content("Settings",
div()
.flex_col()
.gap(8.0)
.child(text("Option 1"))
.child(text("Option 2"))
)
}
Components with Context
For components needing state or animations:
#![allow(unused)]
fn main() {
use blinc_layout::stateful::stateful;
fn counter_card(ctx: &WindowedContext) -> impl ElementBuilder {
let count = ctx.use_state_keyed("counter_card_count", || 0i32);
let card_handle = ctx.use_state(ButtonState::Idle);
stateful(card_handle)
.p(16.0)
.rounded(12.0)
.bg(Color::rgba(0.15, 0.15, 0.2, 1.0))
.flex_col()
.gap(12.0)
.deps(&[count.signal_id()])
.on_state(move |_state, container| {
let current = count.get();
container.merge(
div()
.child(text(&format!("Count: {}", current)).color(Color::WHITE))
);
})
.child(increment_btn(ctx, count))
}
fn increment_btn(ctx: &WindowedContext, count: State<i32>) -> impl ElementBuilder {
let handle = ctx.use_state(ButtonState::Idle);
stateful(handle)
.px(16.0)
.py(8.0)
.rounded(8.0)
.on_state(|state, div| {
let bg = match state {
ButtonState::Idle => Color::rgba(0.3, 0.5, 0.9, 1.0),
ButtonState::Hovered => Color::rgba(0.4, 0.6, 1.0, 1.0),
_ => Color::rgba(0.3, 0.5, 0.9, 1.0),
};
div.set_bg(bg);
})
.on_click(move |_| {
count.update(|v| v + 1);
})
.child(text("+").color(Color::WHITE))
}
}
Animated Components
Use motion() for components with spring animations:
#![allow(unused)]
fn main() {
use blinc_layout::motion::motion;
#[derive(BlincComponent)]
struct AnimatedCard {
#[animation]
scale: f32,
#[animation]
opacity: f32,
}
fn animated_card(ctx: &WindowedContext, title: &str) -> impl ElementBuilder {
let scale = AnimatedCard::use_scale(ctx, 1.0, SpringConfig::snappy());
let opacity = AnimatedCard::use_opacity(ctx, 1.0, SpringConfig::gentle());
let hover_scale = Arc::clone(&scale);
let leave_scale = Arc::clone(&scale);
// motion() is a container - apply transforms to it, style the child
motion()
.scale(scale.lock().unwrap().get())
.opacity(opacity.lock().unwrap().get())
.on_hover_enter(move |_| {
hover_scale.lock().unwrap().set_target(1.05);
})
.on_hover_leave(move |_| {
leave_scale.lock().unwrap().set_target(1.0);
})
.child(
div()
.p(16.0)
.rounded(12.0)
.bg(Color::rgba(0.15, 0.15, 0.2, 1.0))
.child(text(title).color(Color::WHITE))
)
}
}
Note: For hover-only visual effects without animations, prefer Stateful instead - it’s more efficient as it doesn’t require continuous redraws.
Stateful Components
Use stateful(handle) for components with visual states:
#![allow(unused)]
fn main() {
use blinc_layout::stateful::stateful;
fn interactive_card(ctx: &WindowedContext, title: &str) -> impl ElementBuilder {
// Use use_state_for with title as key for reusable component
let handle = ctx.use_state_for(title, ButtonState::Idle);
stateful(handle)
.p(16.0)
.rounded(12.0)
.on_state(|state, div| {
let bg = match state {
ButtonState::Idle => Color::rgba(0.15, 0.15, 0.2, 1.0),
ButtonState::Hovered => Color::rgba(0.18, 0.18, 0.25, 1.0),
ButtonState::Pressed => Color::rgba(0.12, 0.12, 0.16, 1.0),
_ => Color::rgba(0.15, 0.15, 0.2, 1.0),
};
div.set_bg(bg);
})
.child(text(title).color(Color::WHITE))
}
}
Builder Pattern
For highly configurable components:
#![allow(unused)]
fn main() {
pub struct CardBuilder {
title: String,
subtitle: Option<String>,
icon: Option<String>,
bg_color: Color,
on_click: Option<Box<dyn Fn()>>,
}
impl CardBuilder {
pub fn new(title: impl Into<String>) -> Self {
Self {
title: title.into(),
subtitle: None,
icon: None,
bg_color: Color::rgba(0.15, 0.15, 0.2, 1.0),
on_click: None,
}
}
pub fn subtitle(mut self, text: impl Into<String>) -> Self {
self.subtitle = Some(text.into());
self
}
pub fn icon(mut self, path: impl Into<String>) -> Self {
self.icon = Some(path.into());
self
}
pub fn bg(mut self, color: Color) -> Self {
self.bg_color = color;
self
}
pub fn build(self) -> Div {
let mut card = div()
.p(16.0)
.rounded(12.0)
.bg(self.bg_color)
.flex_col()
.gap(8.0);
if let Some(icon_path) = self.icon {
card = card.child(
svg(&icon_path).w(24.0).h(24.0).tint(Color::WHITE)
);
}
card = card.child(
text(&self.title)
.size(18.0)
.weight(FontWeight::SemiBold)
.color(Color::WHITE)
);
if let Some(sub) = self.subtitle {
card = card.child(
text(&sub)
.size(14.0)
.color(Color::rgba(0.6, 0.6, 0.7, 1.0))
);
}
card
}
}
// Usage
CardBuilder::new("Settings")
.subtitle("Manage your preferences")
.icon("icons/settings.svg")
.build()
}
Component Libraries
Organize related components in modules:
#![allow(unused)]
fn main() {
// src/components/cards.rs
pub mod cards {
use blinc_app::prelude::*;
pub fn simple_card(title: &str) -> Div {
// ...
}
pub fn image_card(title: &str, image_url: &str) -> Div {
// ...
}
pub fn action_card<F: Fn() + 'static>(title: &str, on_action: F) -> Div {
// ...
}
}
// src/components/mod.rs
pub mod cards;
pub mod buttons;
pub mod inputs;
// Usage
use crate::components::cards::*;
}
Prop Structs
For components with many parameters:
#![allow(unused)]
fn main() {
pub struct NotificationProps {
pub title: String,
pub message: String,
pub variant: NotificationVariant,
pub dismissible: bool,
pub on_dismiss: Option<Box<dyn Fn()>>,
}
pub enum NotificationVariant {
Info,
Success,
Warning,
Error,
}
pub fn notification(props: NotificationProps) -> Div {
let (bg, icon) = match props.variant {
NotificationVariant::Info => (Color::rgba(0.2, 0.4, 0.8, 1.0), "info.svg"),
NotificationVariant::Success => (Color::rgba(0.2, 0.7, 0.4, 1.0), "check.svg"),
NotificationVariant::Warning => (Color::rgba(0.8, 0.6, 0.2, 1.0), "warning.svg"),
NotificationVariant::Error => (Color::rgba(0.8, 0.3, 0.3, 1.0), "error.svg"),
};
div()
.p(16.0)
.rounded(8.0)
.bg(bg)
.flex_row()
.gap(12.0)
.items_center()
.child(svg(icon).w(20.0).h(20.0).tint(Color::WHITE))
.child(
div()
.flex_1()
.flex_col()
.gap(4.0)
.child(text(&props.title).weight(FontWeight::SemiBold).color(Color::WHITE))
.child(text(&props.message).size(14.0).color(Color::rgba(1.0, 1.0, 1.0, 0.8)))
)
}
}
Best Practices
-
Keep components focused - One component, one responsibility.
-
Use
impl ElementBuilder- For maximum flexibility in return types. -
Document public components - Add doc comments explaining usage.
-
Consistent naming - Use descriptive names that indicate the component’s purpose.
-
Default sensible styles - Provide good defaults, allow overrides.
-
Separate stateless and stateful - Pure components are easier to test and reuse.
-
Use BlincComponent for state and animations - Type-safe hooks for both
State<T>andSharedAnimatedValueprevent key collisions. -
Use Stateful for visual states - Hover, press, focus effects should use
Statefulrather than signals. -
Use motion() for animated values - Wrap animated content in
motion()for proper redraws.
Component Library Overview
blinc_cn is a comprehensive component library for Blinc UI, inspired by shadcn/ui. It provides 40+ production-ready, themeable components built on top of blinc_layout.
Installation
Add blinc_cn to your Cargo.toml:
[dependencies]
blinc_cn = { path = "path/to/blinc_cn" }
Quick Start
#![allow(unused)]
fn main() {
use blinc_cn::prelude::*;
fn build_ui() -> impl ElementBuilder {
div()
.flex_col()
.gap(16.0)
.p(24.0)
.child(
card()
.child(card_header()
.child(card_title("Welcome"))
.child(card_description("Get started with blinc_cn")))
.child(card_content()
.child(text("Beautiful, accessible components.")))
.child(card_footer()
.child(button("Get Started")))
)
}
}
Design Principles
Composable
Components are built from smaller primitives that can be combined:
#![allow(unused)]
fn main() {
// Compose dialog from parts
dialog()
.child(dialog_trigger().child(button("Open")))
.child(dialog_content()
.child(dialog_header().child(dialog_title("Title")))
.child(/* content */)
.child(dialog_footer().child(button("Close"))))
}
Themeable
All components use theme tokens and automatically support dark mode:
#![allow(unused)]
fn main() {
// Components adapt to theme automatically
button("Click me") // Uses theme.colors.primary
// Override theme
ThemeState::set_color_scheme(ColorScheme::Dark);
}
Accessible
Components include keyboard navigation and proper semantics:
- Focus management
- Keyboard shortcuts
- Screen reader support (planned)
Component Categories
| Category | Components |
|---|---|
| Buttons | Button |
| Cards | Card, CardHeader, CardContent, CardFooter |
| Dialogs | Dialog, AlertDialog, Sheet, Drawer |
| Forms | Input, Textarea, Checkbox, Switch, Radio, Select, Slider |
| Navigation | Tabs, DropdownMenu, ContextMenu, Breadcrumb, Sidebar |
| Feedback | Alert, Badge, Progress, Spinner, Skeleton, Toast |
| Layout | Avatar, Separator, AspectRatio, ScrollArea, Accordion |
| Data | Tooltip, HoverCard, Popover, Chart |
Prelude
Import common components with the prelude:
#![allow(unused)]
fn main() {
use blinc_cn::prelude::*;
// Includes:
// - All component builders (button, card, dialog, etc.)
// - Variant enums (ButtonVariant, AlertVariant, etc.)
// - Size enums (ButtonSize, AvatarSize, etc.)
// - Common types and traits
}
Next Steps
- Button - Learn about button variants and usage
- Card - Build card-based layouts
- Dialog - Create modal dialogs
- Form Components - Build forms with inputs
Button
Buttons trigger actions or events.
Basic Usage
#![allow(unused)]
fn main() {
use blinc_cn::prelude::*;
button("Click me")
.on_click(|| println!("Clicked!"))
}
Variants
Buttons come in several visual variants:
#![allow(unused)]
fn main() {
// Primary (default) - Main actions
button("Save").variant(ButtonVariant::Primary)
// Secondary - Alternative actions
button("Cancel").variant(ButtonVariant::Secondary)
// Destructive - Dangerous actions
button("Delete").variant(ButtonVariant::Destructive)
// Outline - Bordered style
button("Edit").variant(ButtonVariant::Outline)
// Ghost - Minimal style
button("More").variant(ButtonVariant::Ghost)
// Link - Looks like a link
button("Learn more").variant(ButtonVariant::Link)
}
Sizes
#![allow(unused)]
fn main() {
// Small
button("Small").size(ButtonSize::Sm)
// Default
button("Default").size(ButtonSize::Default)
// Large
button("Large").size(ButtonSize::Lg)
// Icon only (square)
button("").size(ButtonSize::Icon).icon(icons::SETTINGS)
}
With Icons
#![allow(unused)]
fn main() {
use blinc_icons::icons;
// Icon before text
button("Settings")
.icon(icons::SETTINGS)
// Icon after text
button("Next")
.icon_right(icons::ARROW_RIGHT)
// Icon only
button("")
.size(ButtonSize::Icon)
.icon(icons::PLUS)
}
States
#![allow(unused)]
fn main() {
// Disabled
button("Disabled")
.disabled(true)
// Loading
button("Saving...")
.loading(true)
// Full width
button("Submit")
.full_width(true)
}
Event Handling
#![allow(unused)]
fn main() {
button("Submit")
.on_click(|| {
// Handle click
submit_form();
})
.on_hover(|hovering| {
// Handle hover state
if hovering {
show_tooltip();
}
})
}
Button Groups
#![allow(unused)]
fn main() {
div()
.flex_row()
.gap(8.0)
.child(button("Save").variant(ButtonVariant::Primary))
.child(button("Cancel").variant(ButtonVariant::Outline))
}
Examples
Form Submit Button
#![allow(unused)]
fn main() {
button("Create Account")
.variant(ButtonVariant::Primary)
.size(ButtonSize::Lg)
.full_width(true)
.on_click(|| handle_submit())
}
Icon Button
#![allow(unused)]
fn main() {
button("")
.size(ButtonSize::Icon)
.variant(ButtonVariant::Ghost)
.icon(icons::X)
.on_click(|| close_dialog())
}
Loading Button
#![allow(unused)]
fn main() {
let is_loading = use_state(false);
button(if is_loading { "Saving..." } else { "Save" })
.loading(is_loading)
.disabled(is_loading)
.on_click(|| {
set_loading(true);
save_data().then(|| set_loading(false));
})
}
API Reference
Props
| Prop | Type | Default | Description |
|---|---|---|---|
variant | ButtonVariant | Primary | Visual style |
size | ButtonSize | Default | Button size |
disabled | bool | false | Disable interaction |
loading | bool | false | Show loading state |
full_width | bool | false | Expand to full width |
icon | &str | None | Icon before text |
icon_right | &str | None | Icon after text |
Events
| Event | Type | Description |
|---|---|---|
on_click | Fn() | Called when clicked |
on_hover | Fn(bool) | Called on hover change |
Card
Cards group related content and actions.
Basic Usage
#![allow(unused)]
fn main() {
use blinc_cn::prelude::*;
card()
.child(card_header()
.child(card_title("Card Title"))
.child(card_description("Card description text")))
.child(card_content()
.child(text("Card content goes here.")))
.child(card_footer()
.child(button("Action")))
}
Card Parts
card()
The container that wraps all card content.
#![allow(unused)]
fn main() {
card()
.w(400.0) // Custom width
.child(/* card parts */)
}
card_header()
Contains the title and description.
#![allow(unused)]
fn main() {
card_header()
.child(card_title("Title"))
.child(card_description("Description"))
}
card_title()
The main heading of the card.
#![allow(unused)]
fn main() {
card_title("Account Settings")
}
card_description()
Secondary text below the title.
#![allow(unused)]
fn main() {
card_description("Manage your account preferences")
}
card_content()
The main content area.
#![allow(unused)]
fn main() {
card_content()
.child(/* any content */)
}
card_footer()
Actions and secondary information at the bottom.
#![allow(unused)]
fn main() {
card_footer()
.child(button("Cancel").variant(ButtonVariant::Outline))
.child(button("Save"))
}
Examples
Simple Card
#![allow(unused)]
fn main() {
card()
.child(card_header()
.child(card_title("Notifications"))
.child(card_description("Configure notification settings")))
.child(card_content()
.child(
div()
.flex_col()
.gap(12.0)
.child(checkbox().checked(true).child(label("Email notifications")))
.child(checkbox().child(label("Push notifications")))
))
}
Card with Image
#![allow(unused)]
fn main() {
card()
.overflow_clip()
.child(
img("cover.jpg")
.w_full()
.h(200.0)
.cover()
)
.child(card_header()
.child(card_title("Beautiful Sunset"))
.child(card_description("Photo by @photographer")))
.child(card_footer()
.child(button("View").variant(ButtonVariant::Outline))
.child(button("Download")))
}
Card with Form
#![allow(unused)]
fn main() {
card()
.w(350.0)
.child(card_header()
.child(card_title("Login"))
.child(card_description("Enter your credentials")))
.child(card_content()
.child(
div()
.flex_col()
.gap(16.0)
.child(
div()
.flex_col()
.gap(4.0)
.child(label("Email"))
.child(input().placeholder("name@example.com"))
)
.child(
div()
.flex_col()
.gap(4.0)
.child(label("Password"))
.child(input().input_type("password"))
)
))
.child(card_footer()
.child(button("Sign in").full_width(true)))
}
Card Grid
#![allow(unused)]
fn main() {
div()
.grid()
.grid_cols(3)
.gap(16.0)
.child(
card()
.child(card_header().child(card_title("Plan A")))
.child(card_content().child(text("$9/month")))
.child(card_footer().child(button("Select")))
)
.child(
card()
.child(card_header().child(card_title("Plan B")))
.child(card_content().child(text("$19/month")))
.child(card_footer().child(button("Select")))
)
.child(
card()
.child(card_header().child(card_title("Plan C")))
.child(card_content().child(text("$29/month")))
.child(card_footer().child(button("Select")))
)
}
Interactive Card
#![allow(unused)]
fn main() {
card()
.on_click(|| navigate_to("/details"))
.cursor("pointer")
.child(card_header()
.child(card_title("Click Me"))
.child(card_description("This entire card is clickable")))
.child(card_content()
.child(text("Card content...")))
}
Styling
Cards automatically use theme tokens:
- Background:
theme.colors.card - Border:
theme.colors.border - Radius:
theme.radius.lg - Shadow:
theme.shadows.sm
Override with custom styles:
#![allow(unused)]
fn main() {
card()
.bg(Color::rgb(0.1, 0.1, 0.1))
.border(2.0, Color::BLUE)
.rounded(16.0)
.shadow(Shadow::lg())
}
API Reference
card()
| Prop | Type | Description |
|---|---|---|
| Standard div props | - | All div styling props |
card_header()
| Prop | Type | Description |
|---|---|---|
| Standard div props | - | All div styling props |
card_title()
| Prop | Type | Description |
|---|---|---|
| Text content | &str | Title text |
card_description()
| Prop | Type | Description |
|---|---|---|
| Text content | &str | Description text |
Dialog
Dialogs display content in a modal overlay that requires user interaction.
Basic Usage
#![allow(unused)]
fn main() {
use blinc_cn::prelude::*;
let is_open = use_state(false);
dialog()
.open(is_open)
.on_open_change(|open| set_is_open(open))
.child(dialog_trigger()
.child(button("Open Dialog")))
.child(dialog_content()
.child(dialog_header()
.child(dialog_title("Dialog Title"))
.child(dialog_description("Dialog description")))
.child(text("Dialog content goes here."))
.child(dialog_footer()
.child(button("Close").on_click(|| set_is_open(false)))))
}
Dialog Parts
dialog()
The root component that manages open state.
#![allow(unused)]
fn main() {
dialog()
.open(is_open)
.on_open_change(|open| set_open(open))
}
dialog_trigger()
The element that opens the dialog when clicked.
#![allow(unused)]
fn main() {
dialog_trigger()
.child(button("Open"))
}
dialog_content()
The modal content container with backdrop.
#![allow(unused)]
fn main() {
dialog_content()
.child(/* dialog parts */)
}
dialog_header()
Contains title and description.
#![allow(unused)]
fn main() {
dialog_header()
.child(dialog_title("Title"))
.child(dialog_description("Description"))
}
dialog_footer()
Contains action buttons.
#![allow(unused)]
fn main() {
dialog_footer()
.child(button("Cancel").variant(ButtonVariant::Outline))
.child(button("Confirm"))
}
dialog_close()
A button that closes the dialog.
#![allow(unused)]
fn main() {
dialog_close()
.child(button("Close"))
}
Alert Dialog
For destructive or important confirmations:
#![allow(unused)]
fn main() {
let is_open = use_state(false);
alert_dialog()
.open(is_open)
.on_open_change(|open| set_is_open(open))
.child(alert_dialog_trigger()
.child(button("Delete").variant(ButtonVariant::Destructive)))
.child(alert_dialog_content()
.child(alert_dialog_header()
.child(alert_dialog_title("Are you sure?"))
.child(alert_dialog_description(
"This action cannot be undone."
)))
.child(alert_dialog_footer()
.child(alert_dialog_cancel().child(button("Cancel")))
.child(alert_dialog_action()
.child(button("Delete").variant(ButtonVariant::Destructive)))))
}
Sheet
A panel that slides in from the edge:
#![allow(unused)]
fn main() {
let is_open = use_state(false);
sheet()
.open(is_open)
.side(SheetSide::Right) // Left, Right, Top, Bottom
.on_open_change(|open| set_is_open(open))
.child(sheet_trigger()
.child(button("Open Sheet")))
.child(sheet_content()
.child(sheet_header()
.child(sheet_title("Settings")))
.child(/* content */)
.child(sheet_footer()
.child(button("Save changes"))))
}
Drawer
A mobile-friendly bottom sheet:
#![allow(unused)]
fn main() {
let is_open = use_state(false);
drawer()
.open(is_open)
.on_open_change(|open| set_is_open(open))
.child(drawer_trigger()
.child(button("Open Drawer")))
.child(drawer_content()
.child(drawer_header()
.child(drawer_title("Menu")))
.child(/* content */))
}
Examples
Form Dialog
#![allow(unused)]
fn main() {
let is_open = use_state(false);
let name = use_state(String::new());
let email = use_state(String::new());
dialog()
.open(is_open)
.on_open_change(|open| set_is_open(open))
.child(dialog_trigger()
.child(button("Edit Profile")))
.child(dialog_content()
.child(dialog_header()
.child(dialog_title("Edit Profile"))
.child(dialog_description("Update your profile information")))
.child(
div()
.flex_col()
.gap(16.0)
.child(
div().flex_col().gap(4.0)
.child(label("Name"))
.child(input()
.value(&name)
.on_change(|v| set_name(v)))
)
.child(
div().flex_col().gap(4.0)
.child(label("Email"))
.child(input()
.value(&email)
.on_change(|v| set_email(v)))
)
)
.child(dialog_footer()
.child(dialog_close().child(
button("Cancel").variant(ButtonVariant::Outline)
))
.child(button("Save").on_click(|| {
save_profile();
set_is_open(false);
}))))
}
Confirmation Dialog
#![allow(unused)]
fn main() {
let is_open = use_state(false);
alert_dialog()
.open(is_open)
.on_open_change(|open| set_is_open(open))
.child(alert_dialog_trigger()
.child(button("Delete Account").variant(ButtonVariant::Destructive)))
.child(alert_dialog_content()
.child(alert_dialog_header()
.child(alert_dialog_title("Delete Account"))
.child(alert_dialog_description(
"Are you sure you want to delete your account? \
All your data will be permanently removed."
)))
.child(alert_dialog_footer()
.child(alert_dialog_cancel().child(
button("Cancel").variant(ButtonVariant::Outline)
))
.child(alert_dialog_action().child(
button("Delete")
.variant(ButtonVariant::Destructive)
.on_click(|| delete_account())
))))
}
API Reference
dialog()
| Prop | Type | Default | Description |
|---|---|---|---|
open | bool | false | Whether dialog is open |
on_open_change | Fn(bool) | - | Called when open state changes |
sheet()
| Prop | Type | Default | Description |
|---|---|---|---|
open | bool | false | Whether sheet is open |
side | SheetSide | Right | Which side to slide from |
on_open_change | Fn(bool) | - | Called when open state changes |
SheetSide
#![allow(unused)]
fn main() {
enum SheetSide {
Left,
Right,
Top,
Bottom,
}
}
Form Components
Components for building forms: inputs, checkboxes, selects, and more.
Input
Text input field:
#![allow(unused)]
fn main() {
use blinc_cn::prelude::*;
input()
.placeholder("Enter your name...")
.value(name)
.on_change(|value| set_name(value))
}
Input Types
#![allow(unused)]
fn main() {
// Text (default)
input().placeholder("Name")
// Email
input().input_type("email").placeholder("Email")
// Password
input().input_type("password").placeholder("Password")
// Number
input().input_type("number").placeholder("Age")
// Search
input().input_type("search").placeholder("Search...")
}
Input States
#![allow(unused)]
fn main() {
// Disabled
input().disabled(true)
// Read-only
input().readonly(true)
// With error
input().error(true)
}
Textarea
Multi-line text input:
#![allow(unused)]
fn main() {
textarea()
.placeholder("Enter description...")
.rows(4)
.value(description)
.on_change(|value| set_description(value))
}
Checkbox
#![allow(unused)]
fn main() {
checkbox()
.checked(is_checked)
.on_change(|checked| set_checked(checked))
.child(label("Accept terms and conditions"))
}
Indeterminate State
#![allow(unused)]
fn main() {
checkbox()
.checked(some_checked)
.indeterminate(some_checked && !all_checked)
.on_change(|checked| toggle_all(checked))
.child(label("Select all"))
}
Switch
Toggle switch:
#![allow(unused)]
fn main() {
switch_()
.checked(is_enabled)
.on_change(|enabled| set_enabled(enabled))
}
With Label
#![allow(unused)]
fn main() {
div()
.flex_row()
.items_center()
.gap(8.0)
.child(switch_().checked(dark_mode).on_change(|v| set_dark_mode(v)))
.child(label("Dark mode"))
}
Radio Group
#![allow(unused)]
fn main() {
radio_group()
.value(selected)
.on_change(|value| set_selected(value))
.child(
div().flex_col().gap(8.0)
.child(radio_item("small").child(label("Small")))
.child(radio_item("medium").child(label("Medium")))
.child(radio_item("large").child(label("Large")))
)
}
Select
Dropdown selection:
#![allow(unused)]
fn main() {
select()
.value(selected)
.on_change(|value| set_selected(value))
.child(select_trigger()
.child(select_value().placeholder("Select option...")))
.child(select_content()
.child(select_item("opt1").child(text("Option 1")))
.child(select_item("opt2").child(text("Option 2")))
.child(select_item("opt3").child(text("Option 3"))))
}
Grouped Options
#![allow(unused)]
fn main() {
select()
.child(select_trigger().child(select_value()))
.child(select_content()
.child(select_group()
.child(select_label("Fruits"))
.child(select_item("apple").child(text("Apple")))
.child(select_item("banana").child(text("Banana"))))
.child(select_separator())
.child(select_group()
.child(select_label("Vegetables"))
.child(select_item("carrot").child(text("Carrot")))
.child(select_item("broccoli").child(text("Broccoli")))))
}
Combobox
Searchable select with autocomplete:
#![allow(unused)]
fn main() {
combobox()
.value(selected)
.on_change(|value| set_selected(value))
.child(combobox_trigger()
.child(combobox_input().placeholder("Search...")))
.child(combobox_content()
.child(combobox_empty().child(text("No results found")))
.child(combobox_item("react").child(text("React")))
.child(combobox_item("vue").child(text("Vue")))
.child(combobox_item("svelte").child(text("Svelte"))))
}
Slider
Range slider:
#![allow(unused)]
fn main() {
slider()
.value(volume)
.min(0.0)
.max(100.0)
.step(1.0)
.on_change(|value| set_volume(value))
}
Range Slider
#![allow(unused)]
fn main() {
slider()
.value_range(min_price, max_price)
.min(0.0)
.max(1000.0)
.on_change_range(|min, max| {
set_min_price(min);
set_max_price(max);
})
}
Label
#![allow(unused)]
fn main() {
// Associated with input via for
label("Email").for_id("email-input")
// Direct child of input
checkbox()
.child(label("Remember me"))
}
Form Layout Example
#![allow(unused)]
fn main() {
div()
.flex_col()
.gap(24.0)
.max_w(400.0)
// Name field
.child(
div().flex_col().gap(4.0)
.child(label("Name"))
.child(input()
.placeholder("John Doe")
.value(&name)
.on_change(|v| set_name(v)))
)
// Email field
.child(
div().flex_col().gap(4.0)
.child(label("Email"))
.child(input()
.input_type("email")
.placeholder("john@example.com")
.value(&email)
.on_change(|v| set_email(v)))
)
// Country select
.child(
div().flex_col().gap(4.0)
.child(label("Country"))
.child(select()
.value(&country)
.on_change(|v| set_country(v))
.child(select_trigger().child(select_value()))
.child(select_content()
.child(select_item("us").child(text("United States")))
.child(select_item("uk").child(text("United Kingdom")))
.child(select_item("ca").child(text("Canada")))))
)
// Terms checkbox
.child(
checkbox()
.checked(accepted_terms)
.on_change(|v| set_accepted_terms(v))
.child(label("I accept the terms and conditions"))
)
// Submit button
.child(
button("Submit")
.full_width(true)
.disabled(!accepted_terms)
.on_click(|| submit_form())
)
}
Validation
#![allow(unused)]
fn main() {
let email = use_state(String::new());
let email_error = use_derived(|| {
if email.is_empty() {
None
} else if !email.contains('@') {
Some("Invalid email address")
} else {
None
}
});
div().flex_col().gap(4.0)
.child(label("Email"))
.child(input()
.value(&email)
.error(email_error.is_some())
.on_change(|v| set_email(v)))
.child(
email_error.map(|err|
text(err).size(12.0).color(Color::RED)
)
)
}
Navigation Components
Components for navigation: tabs, menus, breadcrumbs, and sidebars.
Tabs
Organize content into tabbed sections:
#![allow(unused)]
fn main() {
use blinc_cn::prelude::*;
tabs()
.value(active_tab)
.on_change(|tab| set_active_tab(tab))
.child(tabs_list()
.child(tabs_trigger("account").child(text("Account")))
.child(tabs_trigger("password").child(text("Password")))
.child(tabs_trigger("settings").child(text("Settings"))))
.child(tabs_content("account")
.child(text("Account settings...")))
.child(tabs_content("password")
.child(text("Password settings...")))
.child(tabs_content("settings")
.child(text("Other settings...")))
}
Dropdown Menu
#![allow(unused)]
fn main() {
dropdown_menu()
.child(dropdown_menu_trigger()
.child(button("Options").icon_right(icons::CHEVRON_DOWN)))
.child(dropdown_menu_content()
.child(dropdown_menu_label("Actions"))
.child(dropdown_menu_item("edit")
.child(icon(icons::EDIT))
.child(text("Edit"))
.on_click(|| edit_item()))
.child(dropdown_menu_item("duplicate")
.child(icon(icons::COPY))
.child(text("Duplicate")))
.child(dropdown_menu_separator())
.child(dropdown_menu_item("delete")
.child(icon(icons::TRASH))
.child(text("Delete"))
.variant(MenuItemVariant::Destructive)))
}
With Keyboard Shortcuts
#![allow(unused)]
fn main() {
dropdown_menu_item("save")
.child(icon(icons::SAVE))
.child(text("Save"))
.child(dropdown_menu_shortcut("⌘S"))
}
Submenu
#![allow(unused)]
fn main() {
dropdown_menu_content()
.child(dropdown_menu_item("new").child(text("New")))
.child(dropdown_menu_sub()
.child(dropdown_menu_sub_trigger()
.child(text("Share")))
.child(dropdown_menu_sub_content()
.child(dropdown_menu_item("email").child(text("Email")))
.child(dropdown_menu_item("link").child(text("Copy Link")))))
}
Context Menu
Right-click menu:
#![allow(unused)]
fn main() {
context_menu()
.child(context_menu_trigger()
.child(div().w(200.0).h(150.0).bg(Color::GRAY)
.child(text("Right-click me"))))
.child(context_menu_content()
.child(context_menu_item("cut").child(text("Cut")))
.child(context_menu_item("copy").child(text("Copy")))
.child(context_menu_item("paste").child(text("Paste")))
.child(context_menu_separator())
.child(context_menu_item("delete").child(text("Delete"))))
}
Menubar
Application menu bar:
#![allow(unused)]
fn main() {
menubar()
.child(menubar_menu()
.child(menubar_trigger().child(text("File")))
.child(menubar_content()
.child(menubar_item("new").child(text("New File")))
.child(menubar_item("open").child(text("Open...")))
.child(menubar_separator())
.child(menubar_item("save").child(text("Save")))
.child(menubar_item("save-as").child(text("Save As...")))))
.child(menubar_menu()
.child(menubar_trigger().child(text("Edit")))
.child(menubar_content()
.child(menubar_item("undo").child(text("Undo")))
.child(menubar_item("redo").child(text("Redo")))))
}
Breadcrumb
Navigation path:
#![allow(unused)]
fn main() {
breadcrumb()
.child(breadcrumb_list()
.child(breadcrumb_item()
.child(breadcrumb_link("Home").href("/")))
.child(breadcrumb_separator())
.child(breadcrumb_item()
.child(breadcrumb_link("Products").href("/products")))
.child(breadcrumb_separator())
.child(breadcrumb_item()
.child(breadcrumb_page("Details")))) // Current page (not a link)
}
With Ellipsis
#![allow(unused)]
fn main() {
breadcrumb()
.child(breadcrumb_list()
.child(breadcrumb_item().child(breadcrumb_link("Home")))
.child(breadcrumb_separator())
.child(breadcrumb_ellipsis()) // Collapsed items
.child(breadcrumb_separator())
.child(breadcrumb_item().child(breadcrumb_link("Category")))
.child(breadcrumb_separator())
.child(breadcrumb_item().child(breadcrumb_page("Current"))))
}
Pagination
#![allow(unused)]
fn main() {
pagination()
.total(100)
.page_size(10)
.current_page(current_page)
.on_page_change(|page| set_current_page(page))
.child(pagination_content()
.child(pagination_previous())
.child(pagination_items())
.child(pagination_next()))
}
Sidebar
Application sidebar navigation:
#![allow(unused)]
fn main() {
sidebar()
.child(sidebar_header()
.child(
div().flex_row().items_center().gap(8.0)
.child(icon(icons::BOX).size(24.0))
.child(text("My App").weight(FontWeight::Bold))
))
.child(sidebar_content()
.child(sidebar_group()
.child(sidebar_group_label("Main"))
.child(sidebar_menu()
.child(sidebar_menu_item("dashboard")
.icon(icons::HOME)
.active(current_route == "dashboard")
.on_click(|| navigate("/dashboard"))
.child(text("Dashboard")))
.child(sidebar_menu_item("projects")
.icon(icons::FOLDER)
.on_click(|| navigate("/projects"))
.child(text("Projects")))
.child(sidebar_menu_item("tasks")
.icon(icons::CHECK_SQUARE)
.on_click(|| navigate("/tasks"))
.child(text("Tasks")))))
.child(sidebar_group()
.child(sidebar_group_label("Settings"))
.child(sidebar_menu()
.child(sidebar_menu_item("settings")
.icon(icons::SETTINGS)
.on_click(|| navigate("/settings"))
.child(text("Settings")))
.child(sidebar_menu_item("help")
.icon(icons::HELP_CIRCLE)
.on_click(|| navigate("/help"))
.child(text("Help"))))))
.child(sidebar_footer()
.child(
div().flex_row().items_center().gap(8.0)
.child(avatar().src("user.jpg").size(AvatarSize::Sm))
.child(text("John Doe"))
))
}
Collapsible Sidebar
#![allow(unused)]
fn main() {
let is_collapsed = use_state(false);
sidebar()
.collapsed(is_collapsed)
.child(sidebar_header()
.child(sidebar_trigger()
.on_click(|| set_is_collapsed(!is_collapsed))))
.child(/* rest of sidebar */)
}
Navigation Menu
Horizontal navigation with dropdowns:
#![allow(unused)]
fn main() {
navigation_menu()
.child(navigation_menu_list()
.child(navigation_menu_item()
.child(navigation_menu_trigger().child(text("Products")))
.child(navigation_menu_content()
.child(navigation_menu_link("analytics").child(text("Analytics")))
.child(navigation_menu_link("reports").child(text("Reports")))))
.child(navigation_menu_item()
.child(navigation_menu_link("pricing").child(text("Pricing"))))
.child(navigation_menu_item()
.child(navigation_menu_link("about").child(text("About")))))
}
Feedback Components
Components for user feedback: alerts, badges, progress indicators, and toasts.
Alert
Display important messages:
#![allow(unused)]
fn main() {
use blinc_cn::prelude::*;
alert()
.child(alert_title("Heads up!"))
.child(alert_description("This is an important message."))
}
Alert Variants
#![allow(unused)]
fn main() {
// Default
alert()
.child(alert_title("Note"))
.child(alert_description("This is a note."))
// Destructive (error/warning)
alert()
.variant(AlertVariant::Destructive)
.child(alert_title("Error"))
.child(alert_description("Something went wrong."))
}
With Icon
#![allow(unused)]
fn main() {
alert()
.child(icon(icons::INFO).size(16.0))
.child(alert_title("Information"))
.child(alert_description("Here's some useful info."))
}
Badge
Small labels for status or counts:
#![allow(unused)]
fn main() {
badge("New")
badge("3").variant(BadgeVariant::Secondary)
badge("Error").variant(BadgeVariant::Destructive)
badge("Beta").variant(BadgeVariant::Outline)
}
Badge Variants
#![allow(unused)]
fn main() {
// Default - primary color
badge("Default")
// Secondary - muted color
badge("Secondary").variant(BadgeVariant::Secondary)
// Destructive - error/warning
badge("Destructive").variant(BadgeVariant::Destructive)
// Outline - bordered
badge("Outline").variant(BadgeVariant::Outline)
}
With Icon
#![allow(unused)]
fn main() {
badge("")
.variant(BadgeVariant::Outline)
.child(icon(icons::CHECK).size(12.0))
.child(text("Verified"))
}
Progress
Progress bar:
#![allow(unused)]
fn main() {
progress()
.value(75.0) // 0-100
}
Indeterminate
#![allow(unused)]
fn main() {
progress()
.indeterminate(true)
}
With Label
#![allow(unused)]
fn main() {
div()
.flex_col()
.gap(4.0)
.child(
div().flex_row().justify_between()
.child(text("Uploading..."))
.child(text(format!("{}%", progress_value)))
)
.child(progress().value(progress_value))
}
Spinner
Loading indicator:
#![allow(unused)]
fn main() {
spinner()
}
Spinner Sizes
#![allow(unused)]
fn main() {
spinner().size(SpinnerSize::Sm) // Small
spinner().size(SpinnerSize::Md) // Medium (default)
spinner().size(SpinnerSize::Lg) // Large
}
In Button
#![allow(unused)]
fn main() {
button(if is_loading { "" } else { "Save" })
.loading(is_loading)
.disabled(is_loading)
}
Skeleton
Placeholder for loading content:
#![allow(unused)]
fn main() {
skeleton().w(200.0).h(20.0)
}
Card Skeleton
#![allow(unused)]
fn main() {
card()
.child(card_header()
.child(skeleton().w(150.0).h(24.0)) // Title placeholder
.child(skeleton().w(200.0).h(16.0))) // Description placeholder
.child(card_content()
.child(skeleton().w_full().h(100.0))) // Content placeholder
}
List Skeleton
#![allow(unused)]
fn main() {
div()
.flex_col()
.gap(12.0)
.child(
div().flex_row().gap(12.0)
.child(skeleton().w(48.0).h(48.0).rounded_full()) // Avatar
.child(
div().flex_col().gap(4.0)
.child(skeleton().w(150.0).h(16.0)) // Name
.child(skeleton().w(100.0).h(14.0))) // Subtitle
)
// Repeat for more items...
}
Toast
Temporary notifications:
#![allow(unused)]
fn main() {
// Show a toast
show_toast(
toast()
.title("Success")
.description("Your changes have been saved.")
);
// With variant
show_toast(
toast()
.variant(ToastVariant::Destructive)
.title("Error")
.description("Failed to save changes.")
);
}
Toast Variants
#![allow(unused)]
fn main() {
// Default
toast().title("Notification")
// Success
toast()
.variant(ToastVariant::Success)
.title("Success")
// Destructive/Error
toast()
.variant(ToastVariant::Destructive)
.title("Error")
}
With Action
#![allow(unused)]
fn main() {
toast()
.title("Event created")
.description("Friday, February 10, 2024")
.action(
toast_action()
.child(button("Undo").size(ButtonSize::Sm))
.on_click(|| undo_action())
)
}
Toast Position
#![allow(unused)]
fn main() {
// Configure toast container position
toaster()
.position(ToasterPosition::TopRight) // TopLeft, TopRight, BottomLeft, BottomRight
}
Examples
Loading State
#![allow(unused)]
fn main() {
let is_loading = use_state(true);
if is_loading {
div()
.flex_col()
.items_center()
.gap(16.0)
.child(spinner().size(SpinnerSize::Lg))
.child(text("Loading..."))
} else {
// Actual content
}
}
Form Submission Feedback
#![allow(unused)]
fn main() {
let status = use_state(FormStatus::Idle);
div()
.flex_col()
.gap(16.0)
.child(/* form fields */)
.child(
match status {
FormStatus::Idle => button("Submit").on_click(|| submit()),
FormStatus::Submitting => button("").loading(true).disabled(true),
FormStatus::Success => alert()
.child(alert_title("Success"))
.child(alert_description("Form submitted successfully!")),
FormStatus::Error(msg) => alert()
.variant(AlertVariant::Destructive)
.child(alert_title("Error"))
.child(alert_description(msg)),
}
)
}
Notification Center
#![allow(unused)]
fn main() {
fn notify_success(message: &str) {
show_toast(
toast()
.variant(ToastVariant::Success)
.title("Success")
.description(message)
.duration(Duration::from_secs(5))
);
}
fn notify_error(message: &str) {
show_toast(
toast()
.variant(ToastVariant::Destructive)
.title("Error")
.description(message)
.duration(Duration::from_secs(10))
);
}
}
Layout Components
Components for layout and structure: avatar, separator, accordion, and more.
Avatar
User profile images with fallback:
#![allow(unused)]
fn main() {
use blinc_cn::prelude::*;
avatar()
.src("user.jpg")
.fallback("JD")
}
Avatar Sizes
#![allow(unused)]
fn main() {
avatar().size(AvatarSize::Sm) // 32px
avatar().size(AvatarSize::Md) // 40px (default)
avatar().size(AvatarSize::Lg) // 48px
avatar().size(AvatarSize::Xl) // 64px
}
Avatar Fallback
#![allow(unused)]
fn main() {
// Initials fallback
avatar()
.src("user.jpg") // If fails to load...
.fallback("JD") // Show initials
// Icon fallback
avatar()
.fallback_icon(icons::USER)
}
Avatar Group
#![allow(unused)]
fn main() {
avatar_group()
.max(3) // Show max 3, then "+N"
.child(avatar().src("user1.jpg"))
.child(avatar().src("user2.jpg"))
.child(avatar().src("user3.jpg"))
.child(avatar().src("user4.jpg"))
.child(avatar().src("user5.jpg"))
// Displays: [avatar1] [avatar2] [avatar3] [+2]
}
Separator
Visual divider:
#![allow(unused)]
fn main() {
// Horizontal (default)
separator()
// Vertical
separator().orientation(Orientation::Vertical)
}
With Label
#![allow(unused)]
fn main() {
div()
.flex_row()
.items_center()
.gap(8.0)
.child(separator().flex_1())
.child(text("or").color(Color::GRAY))
.child(separator().flex_1())
}
Aspect Ratio
Maintain aspect ratio:
#![allow(unused)]
fn main() {
aspect_ratio(16.0 / 9.0)
.child(img("video-thumbnail.jpg").cover())
}
Common Ratios
#![allow(unused)]
fn main() {
// 16:9 (video)
aspect_ratio(16.0 / 9.0)
// 4:3 (classic)
aspect_ratio(4.0 / 3.0)
// 1:1 (square)
aspect_ratio(1.0)
// 3:4 (portrait)
aspect_ratio(3.0 / 4.0)
}
Scroll Area
Custom scrollbars:
#![allow(unused)]
fn main() {
scroll_area()
.h(400.0)
.child(
div()
.flex_col()
.gap(8.0)
.children((0..50).map(|i| text(format!("Item {}", i))))
)
}
Horizontal Scroll
#![allow(unused)]
fn main() {
scroll_area()
.orientation(Orientation::Horizontal)
.w(300.0)
.child(
div()
.flex_row()
.gap(8.0)
.children((0..20).map(|i|
card().w(150.0).child(text(format!("Card {}", i)))
))
)
}
Collapsible
Expandable content:
#![allow(unused)]
fn main() {
let is_open = use_state(false);
collapsible()
.open(is_open)
.on_open_change(|open| set_is_open(open))
.child(collapsible_trigger()
.child(
div().flex_row().items_center().gap(8.0)
.child(text("Show more"))
.child(icon(if is_open { icons::CHEVRON_UP } else { icons::CHEVRON_DOWN }))
))
.child(collapsible_content()
.child(text("Hidden content that expands...")))
}
Accordion
Multiple collapsible sections:
#![allow(unused)]
fn main() {
accordion()
.accordion_type(AccordionType::Single) // Only one open at a time
.child(accordion_item("item-1")
.child(accordion_trigger()
.child(text("Section 1")))
.child(accordion_content()
.child(text("Content for section 1"))))
.child(accordion_item("item-2")
.child(accordion_trigger()
.child(text("Section 2")))
.child(accordion_content()
.child(text("Content for section 2"))))
.child(accordion_item("item-3")
.child(accordion_trigger()
.child(text("Section 3")))
.child(accordion_content()
.child(text("Content for section 3"))))
}
Multiple Open
#![allow(unused)]
fn main() {
accordion()
.accordion_type(AccordionType::Multiple) // Multiple can be open
// ... accordion items
}
Resizable
Resizable panels:
#![allow(unused)]
fn main() {
resizable()
.direction(ResizeDirection::Horizontal)
.child(resizable_panel()
.default_size(30.0) // 30%
.min_size(20.0)
.child(text("Left Panel")))
.child(resizable_handle())
.child(resizable_panel()
.default_size(70.0) // 70%
.child(text("Right Panel")))
}
Vertical Resizable
#![allow(unused)]
fn main() {
resizable()
.direction(ResizeDirection::Vertical)
.child(resizable_panel()
.default_size(50.0)
.child(text("Top Panel")))
.child(resizable_handle())
.child(resizable_panel()
.default_size(50.0)
.child(text("Bottom Panel")))
}
Examples
User List Item
#![allow(unused)]
fn main() {
div()
.flex_row()
.items_center()
.gap(12.0)
.p(12.0)
.child(avatar().src(&user.avatar).fallback(&user.initials))
.child(
div().flex_col()
.child(text(&user.name).weight(FontWeight::Medium))
.child(text(&user.email).size(14.0).color(Color::GRAY))
)
}
FAQ Accordion
#![allow(unused)]
fn main() {
accordion()
.accordion_type(AccordionType::Single)
.child(accordion_item("faq-1")
.child(accordion_trigger()
.child(text("How do I get started?")))
.child(accordion_content()
.child(text("To get started, first install the package..."))))
.child(accordion_item("faq-2")
.child(accordion_trigger()
.child(text("What are the system requirements?")))
.child(accordion_content()
.child(text("You need Rust 1.70+ and..."))))
}
Split Pane Editor
#![allow(unused)]
fn main() {
resizable()
.direction(ResizeDirection::Horizontal)
.h_full()
.child(resizable_panel()
.default_size(25.0)
.min_size(15.0)
.child(sidebar())) // File tree
.child(resizable_handle())
.child(resizable_panel()
.default_size(75.0)
.child(
resizable()
.direction(ResizeDirection::Vertical)
.child(resizable_panel()
.default_size(70.0)
.child(editor())) // Code editor
.child(resizable_handle())
.child(resizable_panel()
.default_size(30.0)
.child(terminal())) // Terminal
))
}
Data Display Components
Components for displaying data: tooltips, popovers, hover cards, charts, and trees.
Tooltip
Brief information on hover:
#![allow(unused)]
fn main() {
use blinc_cn::prelude::*;
tooltip()
.child(tooltip_trigger()
.child(button("Hover me")))
.child(tooltip_content()
.child(text("This is a tooltip")))
}
Tooltip Position
#![allow(unused)]
fn main() {
tooltip()
.side(TooltipSide::Top) // Top (default)
.side(TooltipSide::Bottom) // Bottom
.side(TooltipSide::Left) // Left
.side(TooltipSide::Right) // Right
}
With Arrow
#![allow(unused)]
fn main() {
tooltip()
.child(tooltip_trigger().child(icon(icons::INFO)))
.child(tooltip_content()
.with_arrow(true)
.child(text("More information")))
}
Hover Card
Rich content on hover:
#![allow(unused)]
fn main() {
hover_card()
.child(hover_card_trigger()
.child(text("@username").color(Color::BLUE)))
.child(hover_card_content()
.child(
div().flex_row().gap(12.0)
.child(avatar().src("user.jpg").size(AvatarSize::Lg))
.child(
div().flex_col().gap(4.0)
.child(text("John Doe").weight(FontWeight::Bold))
.child(text("@johndoe").color(Color::GRAY))
.child(text("Software developer at Acme Inc."))
)
))
}
Popover
Interactive content in a popup:
#![allow(unused)]
fn main() {
let is_open = use_state(false);
popover()
.open(is_open)
.on_open_change(|open| set_is_open(open))
.child(popover_trigger()
.child(button("Open Popover")))
.child(popover_content()
.child(
div().flex_col().gap(12.0)
.child(text("Settings").weight(FontWeight::Bold))
.child(
div().flex_col().gap(8.0)
.child(
div().flex_row().justify_between()
.child(label("Notifications"))
.child(switch_())
)
.child(
div().flex_row().justify_between()
.child(label("Dark Mode"))
.child(switch_())
)
)
))
}
Chart
Data visualization:
#![allow(unused)]
fn main() {
chart()
.chart_type(ChartType::Line)
.data(&[
DataPoint::new("Jan", 100.0),
DataPoint::new("Feb", 150.0),
DataPoint::new("Mar", 120.0),
DataPoint::new("Apr", 180.0),
])
.x_label("Month")
.y_label("Sales")
}
Chart Types
#![allow(unused)]
fn main() {
// Line chart
chart().chart_type(ChartType::Line)
// Bar chart
chart().chart_type(ChartType::Bar)
// Area chart
chart().chart_type(ChartType::Area)
// Pie chart
chart().chart_type(ChartType::Pie)
// Histogram
chart().chart_type(ChartType::Histogram)
// Scatter plot
chart().chart_type(ChartType::Scatter)
}
Multi-Series
#![allow(unused)]
fn main() {
chart()
.chart_type(ChartType::Line)
.series("Revenue", &revenue_data, Color::BLUE)
.series("Expenses", &expense_data, Color::RED)
.series("Profit", &profit_data, Color::GREEN)
.legend(true)
}
Bar Chart
#![allow(unused)]
fn main() {
chart()
.chart_type(ChartType::Bar)
.data(&[
DataPoint::new("Q1", 1200.0),
DataPoint::new("Q2", 1500.0),
DataPoint::new("Q3", 1800.0),
DataPoint::new("Q4", 2100.0),
])
.color(Color::BLUE)
.show_values(true)
}
Pie Chart
#![allow(unused)]
fn main() {
chart()
.chart_type(ChartType::Pie)
.data(&[
DataPoint::new("Desktop", 45.0),
DataPoint::new("Mobile", 35.0),
DataPoint::new("Tablet", 20.0),
])
.show_labels(true)
.show_percentages(true)
}
Tree
Hierarchical data display:
#![allow(unused)]
fn main() {
tree()
.child(tree_item("root")
.child(tree_item_content()
.child(icon(icons::FOLDER))
.child(text("Documents")))
.child(tree_item("doc1")
.child(tree_item_content()
.child(icon(icons::FILE))
.child(text("Report.pdf"))))
.child(tree_item("doc2")
.child(tree_item_content()
.child(icon(icons::FILE))
.child(text("Notes.txt")))))
}
Expandable Tree
#![allow(unused)]
fn main() {
tree()
.child(tree_item("projects")
.expandable(true)
.expanded(true)
.child(tree_item_trigger()
.child(icon(icons::FOLDER))
.child(text("Projects")))
.child(tree_item_content()
.child(tree_item("project1")
.child(tree_item_trigger()
.child(icon(icons::FOLDER))
.child(text("Project A")))
.child(tree_item_content()
.child(tree_item("file1")
.child(tree_item_content()
.child(icon(icons::FILE))
.child(text("main.rs"))))))))
}
Selectable Tree
#![allow(unused)]
fn main() {
let selected = use_state(HashSet::new());
tree()
.selectable(true)
.selected(&selected)
.on_select(|ids| set_selected(ids))
.child(/* tree items */)
}
Kbd
Keyboard shortcut display:
#![allow(unused)]
fn main() {
// Single key
kbd("⌘")
// Key combination
div().flex_row().gap(4.0)
.child(kbd("⌘"))
.child(kbd("K"))
// In context
div().flex_row().items_center().gap(8.0)
.child(text("Search"))
.child(
div().flex_row().gap(2.0)
.child(kbd("⌘"))
.child(kbd("K"))
)
}
Examples
User Profile Card
#![allow(unused)]
fn main() {
hover_card()
.child(hover_card_trigger()
.child(
div().flex_row().items_center().gap(8.0)
.child(avatar().src(&user.avatar).size(AvatarSize::Sm))
.child(text(&user.name))
))
.child(hover_card_content()
.w(300.0)
.child(
div().flex_col().gap(12.0)
.child(
div().flex_row().gap(12.0)
.child(avatar().src(&user.avatar).size(AvatarSize::Lg))
.child(
div().flex_col()
.child(text(&user.name).weight(FontWeight::Bold))
.child(text(&user.title).color(Color::GRAY))
)
)
.child(text(&user.bio))
.child(
div().flex_row().gap(16.0)
.child(
div().flex_col()
.child(text(&user.followers.to_string()).weight(FontWeight::Bold))
.child(text("Followers").size(12.0).color(Color::GRAY))
)
.child(
div().flex_col()
.child(text(&user.following.to_string()).weight(FontWeight::Bold))
.child(text("Following").size(12.0).color(Color::GRAY))
)
)
))
}
Dashboard Chart
#![allow(unused)]
fn main() {
card()
.child(card_header()
.child(card_title("Revenue Overview"))
.child(card_description("Monthly revenue for 2024")))
.child(card_content()
.child(
chart()
.chart_type(ChartType::Area)
.h(300.0)
.data(&monthly_revenue)
.color(Color::rgba(0.2, 0.5, 1.0, 0.5))
.stroke_color(Color::BLUE)
.x_label("Month")
.y_label("Revenue ($)")
.grid(true)
))
}
File Tree
#![allow(unused)]
fn main() {
tree()
.child(tree_item("src")
.expandable(true)
.expanded(true)
.child(tree_item_trigger()
.child(icon(icons::FOLDER_OPEN))
.child(text("src")))
.child(tree_item_content()
.child(tree_item("main")
.on_click(|| open_file("src/main.rs"))
.child(tree_item_content()
.child(icon(icons::FILE_CODE))
.child(text("main.rs"))))
.child(tree_item("lib")
.on_click(|| open_file("src/lib.rs"))
.child(tree_item_content()
.child(icon(icons::FILE_CODE))
.child(text("lib.rs"))))))
}
Buttons & Inputs
Blinc provides ready-to-use input widgets with built-in state management.
Buttons
Basic Button
#![allow(unused)]
fn main() {
use blinc_layout::widgets::button::{button, Button};
fn my_ui(ctx: &WindowedContext) -> impl ElementBuilder {
let btn_state = ctx.use_state_for("save_btn", ButtonState::Idle);
button(btn_state, "Save")
.on_click(|_| {
println!("Saved!");
})
}
}
Styled Buttons
#![allow(unused)]
fn main() {
button(state, "Primary")
.bg_color(Color::rgba(0.3, 0.5, 0.9, 1.0))
.hover_color(Color::rgba(0.4, 0.6, 1.0, 1.0))
.pressed_color(Color::rgba(0.2, 0.4, 0.8, 1.0))
.text_color(Color::WHITE)
.rounded(8.0)
.p(2.0)
}
Custom Content Buttons
#![allow(unused)]
fn main() {
Button::with_content(state, |s| {
div()
.flex_row()
.gap(8.0)
.items_center()
.child(svg("icons/save.svg").w(16.0).h(16.0).tint(Color::WHITE))
.child(text("Save").color(Color::WHITE))
})
.on_click(|_| save_file())
}
Disabled Buttons
#![allow(unused)]
fn main() {
let state = ctx.use_state_for("btn", ButtonState::Disabled);
button(state, "Cannot Click")
.disabled_color(Color::rgba(0.2, 0.2, 0.25, 0.5))
}
Checkboxes
Basic Checkbox
#![allow(unused)]
fn main() {
use blinc_layout::widgets::checkbox::{checkbox, checkbox_state};
fn my_ui(ctx: &WindowedContext) -> impl ElementBuilder {
let state = checkbox_state(false); // Initially unchecked
checkbox(&state)
.on_change(|checked| {
println!("Checkbox is now: {}", checked);
})
}
}
Labeled Checkbox
#![allow(unused)]
fn main() {
checkbox(&state)
.label("Remember me")
.label_color(Color::WHITE)
}
Styled Checkbox
#![allow(unused)]
fn main() {
checkbox(&state)
.check_color(Color::rgba(0.4, 0.6, 1.0, 1.0))
.bg_color(Color::rgba(0.2, 0.2, 0.25, 1.0))
.rounded(4.0)
.size(20.0)
}
Initially Checked
#![allow(unused)]
fn main() {
let state = checkbox_state(true); // Start checked
}
Text Input
Basic Text Input
#![allow(unused)]
fn main() {
use blinc_layout::widgets::text_input::{text_input, text_input_state};
fn my_ui(ctx: &WindowedContext) -> impl ElementBuilder {
let state = text_input_state("Enter your name...");
text_input(&state)
.w(300.0)
.on_change(|text| {
println!("Input: {}", text);
})
}
}
Styled Text Input
#![allow(unused)]
fn main() {
text_input(&state)
.w(300.0)
.rounded(8.0)
.bg_color(Color::rgba(0.15, 0.15, 0.2, 1.0))
.text_color(Color::WHITE)
.placeholder_color(Color::rgba(0.5, 0.5, 0.6, 1.0))
.focus_border_color(Color::rgba(0.4, 0.6, 1.0, 1.0))
}
Reading Input Value
#![allow(unused)]
fn main() {
let state = text_input_state("");
// Later, read the current value
let current_text = state.text();
}
Text Area
Basic Text Area
#![allow(unused)]
fn main() {
use blinc_layout::widgets::text_area::{text_area, text_area_state};
fn my_ui(ctx: &WindowedContext) -> impl ElementBuilder {
let state = text_area_state("Enter description...");
text_area(&state)
.w(400.0)
.h(200.0)
.on_change(|text| {
println!("Content: {}", text);
})
}
}
Styled Text Area
#![allow(unused)]
fn main() {
text_area(&state)
.w(400.0)
.h(200.0)
.rounded(8.0)
.bg_color(Color::rgba(0.15, 0.15, 0.2, 1.0))
.text_color(Color::WHITE)
.font_size(14.0)
.line_height(1.5)
}
Code Editor
Syntax Highlighted Code
use blinc_layout::widgets::code::code;
fn my_ui() -> impl ElementBuilder {
let source = r#"
fn main() {
println!("Hello, Blinc!");
}
"#;
code(source)
.lang("rust")
.w_full()
.h(300.0)
.rounded(8.0)
.font("Fira Code")
.size(14.0)
}
Supported Languages
rust,python,javascript,typescripthtml,css,json,yaml,xmlsql,bash,go,java,c,cpp- And more…
Form Example
#![allow(unused)]
fn main() {
fn login_form(ctx: &WindowedContext) -> impl ElementBuilder {
let email_state = text_input_state("Email address");
let password_state = text_input_state("Password");
let remember_state = checkbox_state(false);
let submit_state = ctx.use_state_for("submit", ButtonState::Idle);
div()
.w(400.0)
.p(24.0)
.rounded(16.0)
.bg(Color::rgba(0.12, 0.12, 0.16, 1.0))
.flex_col()
.gap(16.0)
// Title
.child(
text("Sign In")
.size(24.0)
.weight(FontWeight::Bold)
.color(Color::WHITE)
)
// Email field
.child(
div()
.flex_col()
.gap(4.0)
.child(label("Email").color(Color::WHITE))
.child(
text_input(&email_state)
.w_full()
.rounded(8.0)
)
)
// Password field
.child(
div()
.flex_col()
.gap(4.0)
.child(label("Password").color(Color::WHITE))
.child(
text_input(&password_state)
.w_full()
.rounded(8.0)
// Note: password masking would be a feature to add
)
)
// Remember me
.child(
checkbox(&remember_state)
.label("Remember me")
.label_color(Color::WHITE)
)
// Submit button
.child(
button(submit_state, "Sign In")
.w_full()
.bg_color(Color::rgba(0.3, 0.5, 0.9, 1.0))
.text_color(Color::WHITE)
.rounded(8.0)
.on_click(|_| {
println!("Form submitted!");
})
)
}
}
Widget State Types
Each widget uses a specific state type:
| Widget | State Type | States |
|---|---|---|
| Button | ButtonState | Idle, Hovered, Pressed, Disabled |
| Checkbox | CheckboxState | UncheckedIdle, UncheckedHovered, CheckedIdle, CheckedHovered |
| TextInput | TextFieldState | Idle, Hovered, Focused, FocusedHovered, Disabled |
| TextArea | TextFieldState | Same as TextInput |
Best Practices
-
Use unique keys for state - Each widget needs its own state key.
-
Handle validation in on_change - Validate input as users type.
-
Provide visual feedback - Use colors to indicate focus and errors.
-
Group related inputs - Use flex containers to organize forms.
-
Add labels - Every input should have an associated label for accessibility.
Text & Rich Text
Blinc provides two main elements for displaying text: text() for plain text and rich_text() for inline-formatted text with HTML-like markup.
Plain Text
The text() element is the simplest way to display text:
#![allow(unused)]
fn main() {
use blinc_layout::prelude::*;
// Basic text
text("Hello, World!")
// Styled text
text("Styled text")
.size(24.0)
.color(Color::BLUE)
.bold()
.italic()
// Text with decorations
text("Underlined and struck")
.underline()
.strikethrough()
}
Text Properties
| Method | Description |
|---|---|
.size(f32) | Font size in pixels |
.color(Color) | Text color |
.bold() | Bold weight |
.italic() | Italic style |
.underline() | Underline decoration |
.strikethrough() | Strikethrough decoration |
.align(TextAlign) | Horizontal alignment (Left, Center, Right) |
.v_align(TextVerticalAlign) | Vertical alignment (Top, Middle, Bottom) |
.font_family(FontFamily) | Custom font family |
.line_height(f32) | Line height multiplier (default: 1.2) |
.wrap(bool) | Enable/disable text wrapping |
Rich Text
The rich_text() element supports inline formatting using HTML-like tags. This is ideal for text that needs mixed styling within a single block.
#![allow(unused)]
fn main() {
use blinc_layout::prelude::*;
// Basic formatting
rich_text("This has <b>bold</b> and <i>italic</i> text.")
.size(16.0)
.default_color(Color::WHITE)
// Nested tags
rich_text("<b>Bold with <i>nested italic</i></b>")
// Inline colors
rich_text(r#"Colors: <span color="#FF0000">red</span> and <span color="blue">blue</span>"#)
// Links (clickable, opens in browser)
rich_text(r#"Visit <a href="https://example.com">our website</a> for more info."#)
}
Supported Tags
| Tag | Effect |
|---|---|
<b>, <strong> | Bold text |
<i>, <em> | Italic text |
<u> | Underlined text |
<s>, <strike>, <del> | Strikethrough text |
<a href="url"> | Clickable link (auto-underlined) |
<span color="..."> | Inline color |
Color Formats
The <span color="..."> tag supports multiple color formats:
#![allow(unused)]
fn main() {
// Hex colors
rich_text(r#"<span color="#FF0000">Red</span>"#)
rich_text(r#"<span color="#F00">Short hex</span>"#)
rich_text(r#"<span color="#FF000080">With alpha</span>"#)
// Named colors (CSS subset)
rich_text(r#"<span color="crimson">Crimson</span>"#)
rich_text(r#"<span color="steelblue">Steel Blue</span>"#)
// RGB/RGBA
rich_text(r#"<span color="rgb(255, 128, 0)">Orange</span>"#)
}
Supported named colors: black, white, red, green, blue, yellow, cyan, magenta, gray, silver, maroon, olive, navy, purple, teal, orange, pink, brown, lime, coral, gold, indigo, violet, crimson, salmon, tomato, skyblue, steelblue, transparent
HTML Entity Decoding
Rich text automatically decodes common HTML entities:
#![allow(unused)]
fn main() {
rich_text("Use <b> for bold") // Renders: Use <b> for bold
rich_text("© 2024 • All Rights Reserved ™")
rich_text("“Smart quotes” — and …")
}
Supported entities: <, >, &, ", ', , ©, ®, ™, —, –, …, ‘, ’, “, ”, •, ·, and numeric entities (A, A)
Range-Based API
For programmatic control, use the range-based API with byte indices:
#![allow(unused)]
fn main() {
// Style specific byte ranges
rich_text("Hello World")
.bold_range(0..5) // "Hello" is bold
.color_range(6..11, Color::CYAN) // "World" is cyan
.size(18.0)
.default_color(Color::WHITE)
// Multiple overlapping styles
rich_text("Important Notice: Please read carefully!")
.bold_range(0..16) // "Important Notice" bold
.color_range(0..9, Color::ORANGE) // "Important" orange
.underline_range(18..39) // "Please read carefully" underlined
}
Available range methods:
.bold_range(Range<usize>).italic_range(Range<usize>).underline_range(Range<usize>).strikethrough_range(Range<usize>).color_range(Range<usize>, Color).link_range(Range<usize>, url: &str)
Interactive Links
Links in rich text are fully interactive:
- Click to open: Clicking a link opens the URL in the system’s default browser
- Pointer cursor: The cursor changes to a pointer when hovering over links
- Auto-underlined: Links are automatically underlined for visibility
#![allow(unused)]
fn main() {
rich_text(r#"
Check the <a href="https://docs.example.com">documentation</a>
or view the <a href="https://github.com/example">source code</a>.
"#)
.size(14.0)
.default_color(Color::WHITE)
}
Standalone Links
For simple clickable text, use the link() widget:
#![allow(unused)]
fn main() {
// Default behavior - opens URL in browser
link("Click here", "https://example.com")
// Custom styling
link("Styled link", "https://example.com")
.size(18.0)
.color(Color::CYAN)
.no_underline()
// Underline only on hover
link("Hover to see underline", "https://example.com")
.underline_on_hover()
}
From StyledText
For integration with syntax highlighting or markdown rendering, create rich text from a pre-built StyledText:
#![allow(unused)]
fn main() {
use blinc_layout::styled_text::{StyledText, StyledLine, TextSpan};
let styled = StyledText {
lines: vec![
StyledLine {
text: "Hello World".to_string(),
spans: vec![
TextSpan {
start: 0,
end: 5,
bold: true,
color: Color::RED,
..Default::default()
},
],
},
],
};
rich_text_styled(styled)
.size(16.0)
.default_color(Color::WHITE)
}
Example
Here’s a complete example demonstrating various text features:
#![allow(unused)]
fn main() {
use blinc_app::prelude::*;
use blinc_core::Color;
fn demo_ui() -> impl ElementBuilder {
div()
.flex_col()
.gap(16.0)
.p(20.0)
// Plain text
.child(
text("Plain Text Example")
.size(24.0)
.color(Color::WHITE)
.bold()
)
// Rich text with inline formatting
.child(
rich_text("This is <b>bold</b>, <i>italic</i>, and <span color=\"#00FF00\">green</span>.")
.size(16.0)
.default_color(Color::WHITE)
)
// Interactive link
.child(
rich_text(r#"Visit <a href="https://github.com">GitHub</a> for more."#)
.size(16.0)
.default_color(Color::WHITE)
)
// Range-based styling
.child(
rich_text("Programmatic styling with ranges")
.bold_range(0..13)
.color_range(14..21, Color::CYAN)
.underline_range(22..32)
.size(16.0)
.default_color(Color::WHITE)
)
}
}
Run the rich text demo to see all features in action:
cargo run -p blinc_app --example rich_text_demo --features windowed
Scroll Containers
Blinc provides scroll containers with WebKit-style momentum scrolling and bounce physics.
Basic Scroll
#![allow(unused)]
fn main() {
use blinc_layout::widgets::scroll::scroll;
fn scrollable_content() -> impl ElementBuilder {
scroll()
.h(400.0)
.child(
div()
.flex_col()
.gap(8.0)
.child(/* ... long content ... */)
)
}
}
Scroll Without Bounce
#![allow(unused)]
fn main() {
use blinc_layout::widgets::scroll::scroll_no_bounce;
scroll_no_bounce()
.h(400.0)
.child(content)
}
Scroll Configuration
#![allow(unused)]
fn main() {
use blinc_layout::widgets::scroll::{Scroll, ScrollConfig, ScrollDirection};
use blinc_animation::SpringConfig;
Scroll::with_config(ScrollConfig {
bounce_enabled: true,
bounce_spring: SpringConfig::wobbly(),
deceleration: 1500.0,
velocity_threshold: 10.0,
max_overscroll: 0.3, // 30% of viewport
direction: ScrollDirection::Vertical,
})
.h(400.0)
.child(content)
}
Configuration Presets
#![allow(unused)]
fn main() {
ScrollConfig::default() // Standard bounce
ScrollConfig::no_bounce() // No bounce physics
ScrollConfig::stiff_bounce() // Tight, minimal bounce
ScrollConfig::gentle_bounce() // Soft, more bounce
}
Scroll Directions
#![allow(unused)]
fn main() {
// Vertical only (default)
Scroll::with_config(ScrollConfig {
direction: ScrollDirection::Vertical,
..Default::default()
})
// Horizontal only
Scroll::with_config(ScrollConfig {
direction: ScrollDirection::Horizontal,
..Default::default()
})
// Both directions
Scroll::with_config(ScrollConfig {
direction: ScrollDirection::Both,
..Default::default()
})
}
Scroll States
Scroll containers use ScrollState for physics-driven behavior:
#![allow(unused)]
fn main() {
ScrollState::Idle // Not scrolling
ScrollState::Scrolling // User is dragging
ScrollState::Decelerating // Momentum after release
ScrollState::Bouncing // Edge bounce animation
}
Example: Scrollable List
#![allow(unused)]
fn main() {
fn message_list() -> impl ElementBuilder {
scroll()
.h(500.0)
.w_full()
.child(
div()
.flex_col()
.gap(8.0)
.p(16.0)
.child(
(0..50).map(|i| {
div()
.p(12.0)
.rounded(8.0)
.bg(Color::rgba(0.15, 0.15, 0.2, 1.0))
.child(
text(&format!("Message {}", i + 1))
.color(Color::WHITE)
)
})
)
)
}
}
Example: Horizontal Gallery
#![allow(unused)]
fn main() {
fn image_gallery() -> impl ElementBuilder {
Scroll::with_config(ScrollConfig {
direction: ScrollDirection::Horizontal,
..Default::default()
})
.h(200.0)
.w_full()
.child(
div()
.flex_row()
.gap(16.0)
.p(16.0)
.child(
(0..10).map(|i| {
div()
.w(150.0)
.h(150.0)
.rounded(12.0)
.bg(Color::rgba(0.2, 0.3, 0.5, 1.0))
.flex_center()
.child(text(&format!("{}", i + 1)).size(24.0).color(Color::WHITE))
})
)
)
}
}
Nested Scrolling
Scroll containers handle nested scrolling automatically. Inner scrolls consume events when they can scroll; outer scrolls take over at boundaries.
#![allow(unused)]
fn main() {
fn nested_scroll_example() -> impl ElementBuilder {
// Outer vertical scroll
scroll()
.h(600.0)
.child(
div()
.flex_col()
.gap(16.0)
.child(text("Section 1").size(24.0))
// Inner horizontal scroll
.child(
Scroll::with_config(ScrollConfig {
direction: ScrollDirection::Horizontal,
..Default::default()
})
.h(120.0)
.child(horizontal_items())
)
.child(text("Section 2").size(24.0))
.child(more_content())
)
}
}
Physics Parameters
| Parameter | Default | Description |
|---|---|---|
deceleration | 1500.0 | How quickly momentum decays (higher = faster stop) |
velocity_threshold | 10.0 | Minimum velocity to continue momentum |
max_overscroll | 0.3 | Maximum overscroll as fraction of viewport |
bounce_spring | wobbly | Spring config for bounce animation |
Programmatic Scroll Control
Blinc provides a powerful selector API for programmatic scroll control through ScrollRef. This allows you to scroll to specific elements, positions, or the top/bottom of content.
Creating a ScrollRef
Use ctx.use_scroll_ref() to create a persistent scroll reference:
#![allow(unused)]
fn main() {
use blinc_layout::selector::{ScrollRef, ScrollOptions, ScrollBehavior, ScrollBlock};
fn my_component(ctx: &WindowedContext) -> impl ElementBuilder {
// Create a ScrollRef - persists across rebuilds
let scroll_ref = ctx.use_scroll_ref("my_scroll");
scroll()
.bind(&scroll_ref) // Bind the ref to this scroll container
.child(content)
}
}
Element IDs
Assign IDs to elements you want to scroll to:
#![allow(unused)]
fn main() {
fn card_list() -> impl ElementBuilder {
div()
.flex_col()
.children(
(0..10).map(|i| {
div()
.id(format!("card-{}", i)) // Assign unique ID
.child(text(&format!("Card {}", i)))
})
)
}
}
Scrolling to Elements
Use scroll_to() or scroll_to_with_options() to scroll to an element by ID:
#![allow(unused)]
fn main() {
// Simple scroll to element
scroll_ref.scroll_to("card-5");
// Scroll with options
scroll_ref.scroll_to_with_options(
"card-5",
ScrollOptions {
behavior: ScrollBehavior::Smooth, // Animate the scroll
block: ScrollBlock::Center, // Center element in viewport
..Default::default()
},
);
}
ScrollOptions
Configure how the scroll behaves:
#![allow(unused)]
fn main() {
ScrollOptions {
behavior: ScrollBehavior::Smooth, // or ScrollBehavior::Auto (instant)
block: ScrollBlock::Center, // Vertical alignment
inline: ScrollInline::Nearest, // Horizontal alignment
}
}
| Block/Inline Value | Description |
|---|---|
Start | Align to top/left of viewport |
Center | Align to center of viewport |
End | Align to bottom/right of viewport |
Nearest | Scroll minimum distance to make visible (default) |
Other Scroll Operations
#![allow(unused)]
fn main() {
// Scroll to top/bottom
scroll_ref.scroll_to_top();
scroll_ref.scroll_to_bottom();
// With smooth animation
scroll_ref.scroll_to_bottom_with_behavior(ScrollBehavior::Smooth);
// Scroll by relative amount
scroll_ref.scroll_by(0.0, 100.0); // Scroll down 100px
// Set absolute offset
scroll_ref.set_scroll_offset(0.0, 500.0);
}
Querying Scroll State
#![allow(unused)]
fn main() {
// Current offset
let (x, y) = scroll_ref.offset();
let y = scroll_ref.scroll_y();
// Content and viewport sizes
let content_size = scroll_ref.content_size();
let viewport_size = scroll_ref.viewport_size();
// Position checks
if scroll_ref.is_at_top() { /* ... */ }
if scroll_ref.is_at_bottom() { /* ... */ }
// Scroll progress (0.0 = top, 1.0 = bottom)
let progress = scroll_ref.scroll_progress();
}
Example: Carousel with Dot Navigation
Here’s a complete example of a horizontal carousel with clickable navigation dots:
#![allow(unused)]
fn main() {
use blinc_app::prelude::*;
use blinc_layout::selector::{ScrollBehavior, ScrollBlock, ScrollOptions, ScrollRef};
use blinc_layout::units::px; // Semantic unit for raw pixels
fn carousel(ctx: &WindowedContext) -> impl ElementBuilder {
let scroll_ref = ctx.use_scroll_ref("carousel_scroll");
let current_index = ctx.use_state_keyed("current_index", || 0usize);
div()
.flex_col()
.items_center()
.gap(16.0)
// Horizontal scroll carousel
.child(
scroll()
.bind(&scroll_ref)
.direction(ScrollDirection::Horizontal)
.w(400.0)
.h(300.0)
.child(
div()
.flex_row()
.gap(20.0)
.padding_x(px(60.0)) // Padding to center first/last cards
.children(
(0..5).map(|i| {
div()
.id(format!("card-{}", i)) // Element ID
.w(280.0)
.h(280.0)
.bg(Color::rgba(0.2, 0.3, 0.5, 1.0))
.rounded(16.0)
.child(text(&format!("Card {}", i + 1)))
})
),
),
)
// Navigation dots
.child(build_dots(ctx, &scroll_ref, ¤t_index))
}
fn build_dots(
ctx: &WindowedContext,
scroll_ref: &ScrollRef,
current_index: &State<usize>,
) -> impl ElementBuilder {
div()
.flex_row()
.gap(12.0)
.children(
(0..5).map(|i| {
let scroll_ref = scroll_ref.clone();
let current_index = current_index.clone();
div()
.w(12.0)
.h(12.0)
.rounded(6.0)
.bg(if i == current_index.get() {
Color::rgba(0.4, 0.6, 1.0, 1.0)
} else {
Color::rgba(0.3, 0.3, 0.4, 1.0)
})
.on_click(move |_| {
current_index.set(i);
scroll_ref.scroll_to_with_options(
&format!("card-{}", i),
ScrollOptions {
behavior: ScrollBehavior::Smooth,
block: ScrollBlock::Center,
..Default::default()
},
);
})
})
)
}
}
Best Practices
-
Set explicit height - Scroll containers need a bounded height to work.
-
Use overflow_clip on parent - Ensure parent clips overflowing content.
-
Prefer vertical for long content - Horizontal scrolling is less intuitive for lists.
-
Consider no-bounce for forms - Disable bounce for content that needs precise positioning.
-
Test nested scrolling - Verify inner/outer scroll interactions work as expected.
-
Use meaningful element IDs - Choose descriptive IDs like
"message-123"or"section-intro"for elements you need to scroll to. -
Prefer
ctx.use_scroll_ref()- Always use the context method rather thanScrollRef::new()for proper reactive integration.
Canvas Drawing
The canvas() element provides direct GPU drawing access for custom graphics, charts, and procedural content.
Basic Usage
#![allow(unused)]
fn main() {
use blinc_core::{DrawContext, Rect, Brush, Color, CornerRadius};
canvas(|ctx: &mut dyn DrawContext, bounds| {
// bounds contains the canvas size
ctx.fill_rect(
Rect::new(0.0, 0.0, bounds.width, bounds.height),
CornerRadius::uniform(8.0),
Brush::Solid(Color::RED),
);
})
.w(200.0)
.h(100.0)
}
Drawing Primitives
Filled Rectangles
#![allow(unused)]
fn main() {
ctx.fill_rect(
Rect::new(x, y, width, height),
CornerRadius::uniform(8.0), // Corner radius
Brush::Solid(Color::BLUE),
);
// No corner radius
ctx.fill_rect(
Rect::new(10.0, 10.0, 100.0, 50.0),
CornerRadius::default(),
Brush::Solid(Color::GREEN),
);
}
Stroked Rectangles
#![allow(unused)]
fn main() {
ctx.stroke_rect(
Rect::new(x, y, width, height),
CornerRadius::uniform(4.0),
2.0, // Stroke width
Brush::Solid(Color::WHITE),
);
}
Circles
#![allow(unused)]
fn main() {
// Filled circle
ctx.fill_circle(
Point::new(cx, cy), // Center
radius,
Brush::Solid(Color::BLUE),
);
// Stroked circle
ctx.stroke_circle(
Point::new(cx, cy),
radius,
2.0, // Stroke width
Brush::Solid(Color::WHITE),
);
}
Text
#![allow(unused)]
fn main() {
use blinc_core::TextStyle;
ctx.draw_text(
"Hello, Canvas!",
Point::new(x, y),
&TextStyle::new(16.0).with_color(Color::WHITE),
);
}
Gradients
#![allow(unused)]
fn main() {
use blinc_core::{Gradient, GradientStop, Point};
// Linear gradient
let gradient = Brush::Gradient(Gradient::linear(
Point::new(0.0, 0.0), // Start
Point::new(200.0, 0.0), // End
Color::rgba(0.9, 0.2, 0.5, 1.0),
Color::rgba(0.2, 0.8, 0.6, 1.0),
));
ctx.fill_rect(
Rect::new(0.0, 0.0, 200.0, 100.0),
CornerRadius::default(),
gradient,
);
// Multi-stop gradient
let gradient = Brush::Gradient(Gradient::linear_with_stops(
Point::new(0.0, 0.0),
Point::new(200.0, 0.0),
vec![
GradientStop::new(0.0, Color::RED),
GradientStop::new(0.5, Color::YELLOW),
GradientStop::new(1.0, Color::GREEN),
],
));
}
Transforms
#![allow(unused)]
fn main() {
use blinc_core::Transform;
// Push transform
ctx.push_transform(Transform::translate(50.0, 50.0));
// Draw in transformed space
ctx.fill_rect(/* ... */);
// Pop transform
ctx.pop_transform();
// Rotation
ctx.push_transform(Transform::rotate(angle_radians));
// ... draw ...
ctx.pop_transform();
// Scale
ctx.push_transform(Transform::scale(2.0, 2.0));
// ... draw ...
ctx.pop_transform();
}
Clipping
#![allow(unused)]
fn main() {
// Push clip region
ctx.push_clip(Rect::new(10.0, 10.0, 100.0, 100.0));
// Only content within clip region is visible
ctx.fill_rect(/* ... */);
// Pop clip
ctx.pop_clip();
}
Example: Animated Spinner
#![allow(unused)]
fn main() {
use std::f32::consts::PI;
fn spinner(ctx: &WindowedContext) -> impl ElementBuilder {
let timeline = ctx.use_animated_timeline();
let entry_id = timeline.lock().unwrap().configure(|t| {
let id = t.add(0, 1000, 0.0, 360.0);
t.set_loop(-1);
t.start();
id
});
let render_timeline = Arc::clone(&timeline);
canvas(move |draw_ctx, bounds| {
let angle_deg = render_timeline.lock().unwrap().get(entry_id).unwrap_or(0.0);
let angle_rad = angle_deg * PI / 180.0;
let cx = bounds.width / 2.0;
let cy = bounds.height / 2.0;
let radius = 30.0;
// Draw spinning segments
for i in 0..8 {
let segment_angle = angle_rad + (i as f32 * PI / 4.0);
let alpha = 1.0 - (i as f32 * 0.1);
let x = cx + segment_angle.cos() * radius;
let y = cy + segment_angle.sin() * radius;
draw_ctx.fill_circle(
Point::new(x, y),
4.0,
Brush::Solid(Color::rgba(0.4, 0.6, 1.0, alpha)),
);
}
})
.w(80.0)
.h(80.0)
}
}
Example: Progress Ring
#![allow(unused)]
fn main() {
fn progress_ring(progress: f32) -> impl ElementBuilder {
canvas(move |ctx, bounds| {
let cx = bounds.width / 2.0;
let cy = bounds.height / 2.0;
let radius = bounds.width.min(bounds.height) / 2.0 - 4.0;
// Background ring
ctx.stroke_circle(
Point::new(cx, cy),
radius,
4.0,
Brush::Solid(Color::rgba(0.2, 0.2, 0.25, 1.0)),
);
// Progress arc (simplified - actual arc drawing would need path API)
// For now, draw segments
let segments = 32;
let filled = (segments as f32 * progress) as i32;
for i in 0..filled {
let angle = (i as f32 / segments as f32) * 2.0 * PI - PI / 2.0;
let x = cx + angle.cos() * radius;
let y = cy + angle.sin() * radius;
ctx.fill_circle(
Point::new(x, y),
3.0,
Brush::Solid(Color::rgba(0.4, 0.6, 1.0, 1.0)),
);
}
// Center text
ctx.draw_text(
&format!("{}%", (progress * 100.0) as i32),
Point::new(cx - 15.0, cy + 6.0),
&TextStyle::new(16.0).with_color(Color::WHITE),
);
})
.w(80.0)
.h(80.0)
}
}
Example: Color Palette
#![allow(unused)]
fn main() {
fn color_palette() -> impl ElementBuilder {
canvas(|ctx, bounds| {
let cols = 8;
let rows = 3;
let cell_w = bounds.width / cols as f32;
let cell_h = bounds.height / rows as f32;
for row in 0..rows {
for col in 0..cols {
let hue = col as f32 / cols as f32;
let sat = 1.0 - (row as f32 * 0.25);
let color = hsv_to_rgb(hue, sat, 0.9);
ctx.fill_rect(
Rect::new(
col as f32 * cell_w,
row as f32 * cell_h,
cell_w - 2.0,
cell_h - 2.0,
),
CornerRadius::uniform(4.0),
Brush::Solid(color),
);
}
}
})
.w(240.0)
.h(90.0)
}
}
Best Practices
-
Set explicit size - Canvas needs width and height to render.
-
Use bounds parameter - Draw relative to
bounds.widthandbounds.height. -
Clone Arcs for closures - Animation values need
Arc::clone()before the render closure. -
Push/pop transforms - Always pop what you push to avoid state leaks.
-
Prefer elements when possible - Use
div(),text()for standard UI; canvas for custom graphics.
Images & SVG
Blinc supports raster images and SVG graphics with flexible sizing and styling options.
Images
Basic Image
#![allow(unused)]
fn main() {
use blinc_layout::image::image;
image("path/to/photo.png")
.w(200.0)
.h(150.0)
}
Image from URL
#![allow(unused)]
fn main() {
image("https://example.com/image.jpg")
.w(300.0)
.h(200.0)
}
Object Fit
Control how the image fills its container:
#![allow(unused)]
fn main() {
image(src)
.w(200.0)
.h(200.0)
.cover() // Fill container, crop if needed (default)
image(src)
.contain() // Fit entirely, may letterbox
image(src)
.fill() // Stretch to fill exactly
image(src)
.scale_down() // Scale down only if larger
image(src)
.no_scale() // No scaling, original size
}
Object Position
Control alignment within the container:
#![allow(unused)]
fn main() {
image(src)
.cover()
.center() // Center (default)
image(src)
.cover()
.top_left()
image(src)
.cover()
.top_center()
image(src)
.cover()
.bottom_right()
// Custom position (0.0 to 1.0)
image(src)
.cover()
.position_xy(0.25, 0.75)
}
Image Filters
#![allow(unused)]
fn main() {
image(src)
.w(200.0)
.h(200.0)
.grayscale(0.5) // 0.0 = color, 1.0 = grayscale
.sepia(0.3) // Sepia tone
.brightness(1.2) // > 1.0 brighter, < 1.0 darker
.contrast(1.1) // > 1.0 more contrast
.saturate(0.8) // < 1.0 less saturated
.hue_rotate(45.0) // Rotate hue (degrees)
.invert(0.2) // Color inversion
.blur(2.0) // Blur radius
}
Lazy Loading
For content-heavy applications with many images (galleries, feeds, chat apps), lazy loading defers image loading until the image is visible in the viewport. This reduces initial memory usage and load time.
#![allow(unused)]
fn main() {
use blinc_layout::prelude::*;
use std::time::Duration;
// Basic lazy loading
img("large-photo.jpg")
.lazy()
.w(300.0)
.h(200.0)
// With placeholder color
img("photo.jpg")
.lazy()
.placeholder_color(Color::rgba(0.2, 0.2, 0.2, 1.0))
.w(300.0)
.h(200.0)
// With gradient placeholder using Brush
img("photo.jpg")
.lazy()
.placeholder_brush(Brush::Gradient(Gradient::linear(
Point::new(0.0, 0.0),
Point::new(1.0, 1.0),
Color::rgba(0.4, 0.6, 1.0, 1.0),
Color::rgba(0.6, 0.4, 1.0, 1.0),
)))
.w(300.0)
.h(200.0)
// With thumbnail placeholder
img("large-photo.jpg")
.lazy()
.placeholder_image("thumbnail.jpg")
.fade_in(Duration::from_millis(300))
.w(300.0)
.h(200.0)
// Skeleton loading animation
img("photo.jpg")
.lazy()
.skeleton()
.fade_in(Duration::from_millis(250))
.w(300.0)
.h(200.0)
// Disable fade animation
img("photo.jpg")
.lazy()
.no_fade()
.w(300.0)
.h(200.0)
}
Loading Strategies
| Strategy | Description |
|---|---|
Eager (default) | Load immediately when element is created |
Lazy | Load only when visible in viewport |
Placeholder Types
| Placeholder | Description |
|---|---|
None | No placeholder (blank until loaded) |
Color(color) | Solid color background |
Brush(brush) | Any brush (gradient, glass effect, etc.) |
Image(url) | Another image (e.g., low-res thumbnail, blur hash) |
Skeleton | Shimmer loading animation |
Emoji Images
Render emoji as images at arbitrary sizes using the system emoji font. Emoji images are automatically lazy-loaded for memory efficiency.
#![allow(unused)]
fn main() {
use blinc_layout::image::{emoji, emoji_sized};
// Default size (64px)
emoji("😀")
// Custom size
emoji_sized("🚀", 128.0)
// In a layout
div()
.flex_row()
.gap(8.0)
.child(emoji_sized("👍", 32.0))
.child(emoji_sized("🎉", 32.0))
.child(emoji_sized("✨", 32.0))
}
Emoji images use the system color emoji font (Apple Color Emoji on macOS, Segoe UI Emoji on Windows, Noto Color Emoji on Linux).
SVG
Basic SVG
#![allow(unused)]
fn main() {
use blinc_layout::svg::svg;
svg("icons/menu.svg")
.w(24.0)
.h(24.0)
}
SVG with Tint
Apply a color tint to monochrome SVGs:
#![allow(unused)]
fn main() {
svg("icons/settings.svg")
.w(24.0)
.h(24.0)
.tint(Color::WHITE)
svg("icons/error.svg")
.w(20.0)
.h(20.0)
.tint(Color::rgba(0.9, 0.3, 0.3, 1.0))
}
SVG Sizing
#![allow(unused)]
fn main() {
// Fixed size
svg(src).w(32.0).h(32.0)
// Square shorthand
svg(src).square(24.0)
// Aspect ratio preserved
svg(src).w(48.0).h_auto()
}
Common Patterns
Avatar Image
#![allow(unused)]
fn main() {
fn avatar(url: &str, size: f32) -> impl ElementBuilder {
image(url)
.w(size)
.h(size)
.cover()
.rounded_full() // Circular
}
}
Icon Button
#![allow(unused)]
fn main() {
use blinc_layout::stateful::stateful;
fn icon_button(ctx: &WindowedContext, icon_path: &str) -> impl ElementBuilder {
// Use use_state_for with icon_path as key for reusable component
let handle = ctx.use_state_for(icon_path, ButtonState::Idle);
stateful(handle)
.w(40.0)
.h(40.0)
.rounded(8.0)
.flex_center()
.on_state(|state, div| {
let bg = match state {
ButtonState::Idle => Color::TRANSPARENT,
ButtonState::Hovered => Color::rgba(0.2, 0.2, 0.25, 1.0),
ButtonState::Pressed => Color::rgba(0.15, 0.15, 0.2, 1.0),
_ => Color::TRANSPARENT,
};
div.set_bg(bg);
})
.child(
svg(icon_path)
.w(20.0)
.h(20.0)
.tint(Color::WHITE)
)
}
}
Image Card
#![allow(unused)]
fn main() {
fn image_card(image_url: &str, title: &str) -> impl ElementBuilder {
div()
.w(300.0)
.rounded(12.0)
.overflow_clip()
.bg(Color::rgba(0.15, 0.15, 0.2, 1.0))
.child(
image(image_url)
.w_full()
.h(180.0)
.cover()
)
.child(
div()
.p(16.0)
.child(
text(title)
.size(18.0)
.weight(FontWeight::SemiBold)
.color(Color::WHITE)
)
)
}
}
Gallery Grid
#![allow(unused)]
fn main() {
fn gallery(images: &[&str]) -> impl ElementBuilder {
div()
.flex_row()
.flex_wrap()
.gap(8.0)
.child(
images.iter().map(|url| {
image(*url)
.w(150.0)
.h(150.0)
.cover()
.rounded(8.0)
})
)
}
}
Placeholder with Fallback
#![allow(unused)]
fn main() {
fn image_with_placeholder(url: Option<&str>) -> impl ElementBuilder {
match url {
Some(src) => image(src)
.w(200.0)
.h(200.0)
.cover()
.rounded(8.0),
None => div()
.w(200.0)
.h(200.0)
.rounded(8.0)
.bg(Color::rgba(0.2, 0.2, 0.25, 1.0))
.flex_center()
.child(
svg("icons/image-placeholder.svg")
.w(48.0)
.h(48.0)
.tint(Color::rgba(0.4, 0.4, 0.5, 1.0))
),
}
}
}
Supported Formats
Images
- PNG
- JPEG
- WebP
- GIF (first frame)
- BMP
- ICO
SVG
- Standard SVG 1.1
- Path elements
- Basic shapes (rect, circle, ellipse, line, polyline, polygon)
- Transforms
- Fill and stroke
Best Practices
-
Set explicit dimensions - Images need width and height for layout.
-
Use
coverfor photos - Fills container nicely without distortion. -
Use
containfor diagrams - Ensures nothing is cropped. -
Tint icons - Use
.tint()to match your color scheme. -
Use SVG for icons - Scales perfectly at any size.
-
Optimize images - Use appropriate formats and compression for web.
-
Use lazy loading for galleries - In scroll containers with many images, use
.lazy()to reduce memory usage and improve initial load time. -
Use emoji images for large emoji - For emoji larger than ~24px, use
emoji_sized()instead of text for crisp rendering.
Markdown Rendering
Blinc includes a built-in markdown renderer that converts CommonMark + GFM markdown to native layout elements.
Basic Usage
#![allow(unused)]
fn main() {
use blinc_layout::markdown::markdown;
// Render markdown to a Div
let content = markdown(r#"
Hello World
This is **bold** and *italic* text.
- List item 1
- List item 2
"#);
// Use in your layout
div()
.flex_col()
.child(content)
}
Themes
The renderer supports light and dark themes:
#![allow(unused)]
fn main() {
use blinc_layout::markdown::{markdown, markdown_light, markdown_with_config, MarkdownConfig};
// Dark theme (default) - for dark backgrounds
let dark_content = markdown("# Dark Theme");
// Light theme - for white/light backgrounds
let light_content = markdown_light("# Light Theme");
// Custom configuration
let custom = markdown_with_config("# Custom", MarkdownConfig {
h1_size: 36.0,
body_size: 16.0,
..MarkdownConfig::default()
});
}
Supported Elements
Text Formatting
| Markdown | Result |
|---|---|
**bold** | bold text |
*italic* | italic text |
~~strikethrough~~ | |
`inline code` | inline code |
[link](url) | clickable link |
#![allow(unused)]
fn main() {
markdown(r#"
This is **bold**, *italic*, and ~~strikethrough~~ text.
Here's some `inline code` and a [link](https://example.com).
"#)
}
Headings
#![allow(unused)]
fn main() {
markdown(r#"
Heading 1
# Heading 2
## Heading 3
### Heading 4
#### Heading 5
##### Heading 6
"#)
}
Lists
Unordered lists:
#![allow(unused)]
fn main() {
markdown(r#"
- First item
- Second item
- Nested item
- Another nested
- Third item
"#)
}
Ordered lists:
#![allow(unused)]
fn main() {
markdown(r#"
1. First step
2. Second step
3. Third step
"#)
}
Task lists (GFM extension):
#![allow(unused)]
fn main() {
markdown(r#"
- [x] Completed task
- [ ] Pending task
- [x] Another done
"#)
}
Code Blocks
Fenced code blocks with optional language:
markdown(r#"
```rust
fn main() {
println!("Hello, Blinc!");
}
“#)
Supported languages for syntax highlighting include Rust, Python, JavaScript, TypeScript, and more.
### Blockquotes
```rust
markdown(r#"
> This is a blockquote.
> It can span multiple lines.
>
> And have multiple paragraphs.
"#)
Tables (GFM)
#![allow(unused)]
fn main() {
markdown(r#"
| Header 1 | Header 2 | Header 3 |
|----------|----------|----------|
| Cell 1 | Cell 2 | Cell 3 |
| Cell 4 | Cell 5 | Cell 6 |
"#)
}
Horizontal Rules
#![allow(unused)]
fn main() {
markdown(r#"
Content above
---
Content below
"#)
}
Images
#![allow(unused)]
fn main() {
markdown(r#"


"#)
}
Configuration
Customize the renderer with MarkdownConfig:
#![allow(unused)]
fn main() {
use blinc_layout::markdown::{markdown_with_config, MarkdownConfig};
use blinc_core::Color;
let config = MarkdownConfig {
// Typography sizes
h1_size: 32.0,
h2_size: 28.0,
h3_size: 24.0,
h4_size: 20.0,
h5_size: 18.0,
h6_size: 16.0,
body_size: 16.0,
code_size: 14.0,
// Colors
text_color: Color::WHITE,
heading_color: Color::WHITE,
link_color: Color::rgba(0.4, 0.6, 1.0, 1.0),
code_bg: Color::rgba(0.1, 0.1, 0.12, 1.0),
code_text: Color::rgba(0.9, 0.6, 0.3, 1.0),
// Spacing
paragraph_spacing: 16.0,
heading_margin_top: 24.0,
heading_margin_bottom: 12.0,
// Lists
list_indent: 24.0,
list_item_spacing: 4.0,
..Default::default()
};
let content = markdown_with_config("# Custom Styled", config);
}
Preset Themes
#![allow(unused)]
fn main() {
// Dark theme (default) - white text on dark backgrounds
let dark = MarkdownConfig::default();
// Light theme - dark text on light backgrounds
let light = MarkdownConfig::light();
}
Live Editor Example
A full markdown editor with live preview is available in the examples:
cargo run -p blinc_app --example markdown_demo --features windowed
This demonstrates:
- TextArea for markdown source editing
- Live preview with
markdown_light() - Stateful reactive updates on text change
HTML Entities
The renderer automatically decodes HTML entities in text:
#![allow(unused)]
fn main() {
markdown(r#"
© 2025 — All rights reserved
Temperature: 72°F
Price: €99.99
"#)
}
Common entities: & (&), < (<), > (>), " ("), (non-breaking space), © (©), ™ (™), and many more.
Best Practices
-
Use
markdown_light()for light backgrounds - The default theme assumes dark backgrounds. -
Wrap in scroll for long content - Markdown can produce tall content:
#![allow(unused)] fn main() { scroll() .h(600.0) .direction(ScrollDirection::Vertical) .child(markdown(long_content)) } -
Set container width - Markdown content respects parent width:
#![allow(unused)] fn main() { div() .w(800.0) .child(markdown(content)) } -
Code blocks need height - For syntax highlighting to render properly, ensure the container has adequate height.
-
Images need explicit dimensions - While images will render, they work best when the markdown container has width constraints.
Element Query API
The Element Query API provides programmatic access to elements in the UI tree, enabling imperative operations like scrolling, focusing, reading bounds, and triggering updates.
Overview
#![allow(unused)]
fn main() {
// Query an element by its string ID
let handle = ctx.query("my-element");
// Check if it exists
if handle.exists() {
// Get computed bounds
if let Some(bounds) = handle.bounds() {
println!("Element at ({}, {}) size {}x{}",
bounds.x, bounds.y, bounds.width, bounds.height);
}
// Scroll into view
handle.scroll_into_view();
// Focus the element
handle.focus();
}
}
Assigning Element IDs
To query an element, it must have a string ID assigned via .id():
#![allow(unused)]
fn main() {
div()
.id("sidebar")
.w(250.0)
.h_full()
.child(
div()
.id("nav-item-home")
.child(text("Home"))
)
.child(
div()
.id("nav-item-settings")
.child(text("Settings"))
)
}
IDs should be unique within your UI. Duplicate IDs will cause the last element to win.
ElementHandle API
Creation & Existence
#![allow(unused)]
fn main() {
// Get a handle - works even if element doesn't exist yet
let handle = ctx.query("my-element");
// Check if element exists in the tree
if handle.exists() {
// Element is rendered
}
// Get the string ID
let id = handle.id(); // "my-element"
}
Bounds & Visibility
#![allow(unused)]
fn main() {
// Get computed bounds after layout
if let Some(bounds) = handle.bounds() {
println!("Position: ({}, {})", bounds.x, bounds.y);
println!("Size: {}x{}", bounds.width, bounds.height);
}
// Check if visible in viewport
if handle.is_visible() {
// Element intersects with window viewport
}
}
Navigation
#![allow(unused)]
fn main() {
// Scroll element into view (smooth scroll)
handle.scroll_into_view();
// Focus the element (for inputs, updates EventRouter)
handle.focus();
// Remove focus
handle.blur();
// Check focus state
if handle.is_focused() {
// Element has keyboard focus
}
}
Tree Traversal
#![allow(unused)]
fn main() {
// Get parent element
if let Some(parent) = handle.parent() {
println!("Parent ID: {}", parent.id());
}
// Iterate over ancestors (parent → grandparent → root)
for ancestor in handle.ancestors() {
println!("Ancestor: {}", ancestor.id());
}
}
Triggering Updates
ElementHandle provides three levels of update granularity:
#![allow(unused)]
fn main() {
// 1. Visual-only update (fastest - skips layout)
// Use for: background color, opacity, shadows, transforms
handle.mark_visual_dirty(
RenderProps::default().with_background(Color::RED.into())
);
// 2. Subtree rebuild with new children
// Use for: structural changes where you know the new content
handle.mark_dirty_subtree(
div().child(text("New content"))
);
// 3. Full rebuild (fallback)
// Triggers complete UI rebuild, diffing determines actual changes
handle.mark_dirty();
}
Signal Integration
#![allow(unused)]
fn main() {
// Emit a signal to trigger reactive updates
// Only rebuilds stateful elements that depend on this signal
handle.emit_signal(my_signal_id);
}
On-Ready Callbacks
Register callbacks that fire once after an element’s first layout:
#![allow(unused)]
fn main() {
ctx.query("progress-bar").on_ready(|bounds| {
// Element has been laid out
println!("Progress bar width: {}", bounds.width);
// Start an animation based on computed size
progress_anim.lock().unwrap().set_target(bounds.width * 0.75);
});
}
On-ready callbacks:
- Fire only once per element ID
- Work even if element doesn’t exist yet (callback queued)
- Survive tree rebuilds (tracked by string ID)
Querying in Event Handlers
Inside event handlers, use the global query() function to get an ElementHandle:
#![allow(unused)]
fn main() {
use blinc_layout::prelude::*;
div()
.on_click(|_| {
// query() returns Option<ElementHandle> - None if element doesn't exist
if let Some(handle) = query("my-element") {
handle.scroll_into_view();
handle.focus();
}
})
}
The query() function uses the global BlincContextState internally, so you don’t need to capture any context or registry in your closures.
For simple operations like scroll and focus without needing the full handle:
#![allow(unused)]
fn main() {
use blinc_core::BlincContextState;
div()
.on_click(|_| {
// Direct access for simple operations
if let Some(ctx) = BlincContextState::try_get() {
ctx.scroll_element_into_view("my-element");
ctx.set_focus(Some("my-input"));
}
})
}
Use Cases
Scroll to Element on Action
#![allow(unused)]
fn main() {
fn scrollable_list(ctx: &WindowedContext) -> impl ElementBuilder {
div()
.flex_col()
.child(
div()
.on_click(|_| {
// Use query() to get handle and scroll
if let Some(handle) = query("list-bottom") {
handle.scroll_into_view();
}
})
.child(text("Jump to Bottom"))
)
.child(
scroll()
.h(400.0)
.child(
div()
.flex_col()
.children((0..100).map(|i| {
div()
.id(format!("item-{}", i))
.child(text(format!("Item {}", i)))
}))
.child(
div().id("list-bottom").h(1.0)
)
)
)
}
}
Focus Management
#![allow(unused)]
fn main() {
fn login_form(ctx: &WindowedContext) -> impl ElementBuilder {
div()
.flex_col()
.gap(16.0)
.child(
text_input(ctx.use_state_keyed::<TextInputState>("username"))
.id("username-input")
.placeholder("Username")
)
.child(
text_input(ctx.use_state_keyed::<TextInputState>("password"))
.id("password-input")
.placeholder("Password")
.on_key_down(|evt| {
if evt.key_code == 9 && evt.shift { // Shift+Tab
if let Some(handle) = query("username-input") {
handle.focus();
}
}
})
)
.child(
div()
.on_click(|_| {
// Focus username on form reset
if let Some(handle) = query("username-input") {
handle.focus();
}
})
.child(text("Reset"))
)
}
}
Measure Element After Layout
#![allow(unused)]
fn main() {
fn responsive_card(ctx: &WindowedContext) -> impl ElementBuilder {
let card_width = ctx.use_signal(0.0f32);
// Register callback to measure after layout
ctx.query("adaptive-card").on_ready(move |bounds| {
// on_ready callback has access to bounds directly
println!("Card width: {}", bounds.width);
});
let width = ctx.get(card_width).unwrap_or(0.0);
let columns = if width > 600.0 { 3 } else if width > 400.0 { 2 } else { 1 };
div()
.id("adaptive-card")
.w_full()
.flex_wrap()
.children((0..9).map(|i| {
div()
.w(pct(100.0 / columns as f32))
.child(text(format!("Item {}", i)))
}))
}
}
Efficient Visual Updates
Use mark_visual_dirty for visual-only changes that don’t affect layout:
#![allow(unused)]
fn main() {
fn highlight_on_selection(ctx: &WindowedContext, selected_id: Option<&str>) -> impl ElementBuilder {
let selected = selected_id.map(|s| s.to_string());
div()
.flex_col()
.children(["item-a", "item-b", "item-c"].iter().map(|id| {
let is_selected = selected.as_deref() == Some(*id);
let id_string = id.to_string();
div()
.id(*id)
.p(12.0)
.bg(if is_selected {
Color::rgba(0.2, 0.5, 1.0, 0.3)
} else {
Color::TRANSPARENT
})
.on_click(move |_| {
// Visual-only update - skips layout recomputation
if let Some(handle) = query(&id_string) {
handle.mark_visual_dirty(
RenderProps::default()
.with_background(Color::rgba(0.2, 0.5, 1.0, 0.3).into())
);
}
})
.child(text(*id))
}))
}
}
Carousel with Snap Points
#![allow(unused)]
fn main() {
fn carousel(ctx: &WindowedContext, items: &[String]) -> impl ElementBuilder {
let current_index = ctx.use_signal(0usize);
div()
.flex_col()
.child(
scroll()
.id("carousel-scroll")
.w(300.0)
.h(200.0)
.scroll_x()
.child(
div()
.flex_row()
.children(items.iter().enumerate().map(|(i, item)| {
div()
.id(format!("slide-{}", i))
.w(300.0)
.h(200.0)
.flex_center()
.child(text(item))
}))
)
)
.child(
div()
.flex_row()
.justify_center()
.gap(8.0)
.children((0..items.len()).map(|i| {
div()
.circle(8.0)
.bg(Color::WHITE.with_alpha(0.5))
.on_click(move |_| {
if let Some(handle) = query(&format!("slide-{}", i)) {
handle.scroll_into_view();
}
})
}))
)
}
---
# Performance Considerations
## Update Granularity
Choose the right update method based on what changed:
| Method | When to Use | Layout Cost |
|--------|-------------|-------------|
| `mark_visual_dirty(props)` | Background, opacity, shadow, transform | None (visual only) |
| `mark_dirty_subtree(div)` | Children structure changed | Subtree only |
| `mark_dirty()` | Unknown changes, fallback | Full rebuild |
| `emit_signal(id)` | Signal-based state change | Targeted stateful |
## Avoid Frequent Queries in Render
```rust
// Bad: Query in render function (called every frame)
fn bad_example(ctx: &WindowedContext) -> impl ElementBuilder {
let bounds = ctx.query("my-element").bounds(); // Called every render!
// ...
}
// Good: Query in event handler or on_ready
fn good_example(ctx: &WindowedContext) -> impl ElementBuilder {
div()
.on_click(|_| {
// query() is designed for use in event handlers
if let Some(handle) = query("my-element") {
let bounds = handle.bounds();
// Use bounds...
}
})
}
}
Use on_ready for Post-Layout Measurements
#![allow(unused)]
fn main() {
// The on_ready callback fires once after first layout
ctx.query("my-element").on_ready(|bounds| {
// Safe to use bounds here - layout is complete
setup_animations_based_on_size(bounds);
});
}
Best Practices
-
Assign meaningful IDs - Use descriptive IDs like
"sidebar","submit-button","user-avatar"rather than generic names. -
Prefer declarative state - Use signals and reactive state for most UI updates. Use ElementHandle for imperative operations like scroll-to and focus.
-
Use visual-only updates - When only colors/opacity/shadows change, use
mark_visual_dirty()to skip layout. -
Handle missing elements - Always check
exists()or handleNonefrombounds()when the element might not be rendered. -
Avoid ID collisions - Each ID should be unique. Consider namespacing like
"dialog-submit","sidebar-nav-home". -
Use on_ready for measurements - Don’t assume bounds are available immediately. Use
on_readyfor post-layout operations.
Overlay System
Blinc provides an overlay system for modals, dialogs, toasts, and context menus.
Overview
Overlays render on top of the main UI and handle their own lifecycle. Access the overlay manager through the context:
#![allow(unused)]
fn main() {
ctx.overlay_manager()
}
Modals
Full-screen overlays with backdrop:
#![allow(unused)]
fn main() {
ctx.overlay_manager()
.modal()
.title("Confirm Action")
.content(|| {
div()
.flex_col()
.gap(16.0)
.child(text("Are you sure you want to proceed?"))
})
.show();
}
Modal with Actions
#![allow(unused)]
fn main() {
ctx.overlay_manager()
.modal()
.title("Delete Item")
.content(|| {
text("This action cannot be undone.")
})
.primary_action("Delete", |_| {
delete_item();
})
.secondary_action("Cancel", |_| {
// Modal closes automatically
})
.show();
}
Dialogs
Centered dialogs with customizable content:
#![allow(unused)]
fn main() {
ctx.overlay_manager()
.dialog()
.title("Settings")
.content(|| build_settings_form())
.primary_action("Save", |_| {
save_settings();
})
.secondary_action("Cancel", |_| {})
.show();
}
Dialog Sizing
#![allow(unused)]
fn main() {
ctx.overlay_manager()
.dialog()
.width(600.0)
.height(400.0)
.title("Large Dialog")
.content(|| content)
.show();
}
Toasts
Brief notifications:
#![allow(unused)]
fn main() {
// Simple toast
ctx.overlay_manager()
.toast("Item saved successfully!")
.show();
// With duration
ctx.overlay_manager()
.toast("Processing...")
.duration(5000) // 5 seconds
.show();
// Positioned
ctx.overlay_manager()
.toast("Copied to clipboard")
.position(ToastPosition::BottomCenter)
.show();
}
Toast Positions
#![allow(unused)]
fn main() {
ToastPosition::TopLeft
ToastPosition::TopCenter
ToastPosition::TopRight
ToastPosition::BottomLeft
ToastPosition::BottomCenter
ToastPosition::BottomRight
}
Context Menus
Right-click menus:
#![allow(unused)]
fn main() {
div()
.on_context_menu(|evt| {
ctx.overlay_manager()
.context_menu()
.item("Copy", || copy_to_clipboard())
.item("Paste", || paste_from_clipboard())
.separator()
.item("Delete", || delete_selected())
.show_at(evt.mouse_x, evt.mouse_y);
})
}
Nested Menus
#![allow(unused)]
fn main() {
ctx.overlay_manager()
.context_menu()
.item("Edit", || {})
.submenu("Export", |menu| {
menu.item("PNG", || export_png())
.item("JPEG", || export_jpeg())
.item("SVG", || export_svg())
})
.show_at(x, y);
}
Dismissing Overlays
Overlays close when:
- User clicks outside (backdrop click)
- Escape key is pressed
- Action callback completes
- Programmatically dismissed
#![allow(unused)]
fn main() {
let overlay_id = ctx.overlay_manager()
.modal()
.title("Loading...")
.content(|| spinner())
.show();
// Later, dismiss programmatically
ctx.overlay_manager().dismiss(overlay_id);
}
Custom Overlay Content
For full control, use a custom overlay:
#![allow(unused)]
fn main() {
ctx.overlay_manager()
.custom(|| {
stack()
.w_full()
.h_full()
// Backdrop
.child(
div()
.w_full()
.h_full()
.bg(Color::rgba(0.0, 0.0, 0.0, 0.5))
)
// Content
.child(
div()
.absolute()
.inset(0.0)
.flex_center()
.child(my_custom_modal())
)
})
.show();
}
Best Practices
-
Use appropriate overlay type - Modal for blocking actions, toast for notifications, dialog for forms.
-
Provide escape routes - Always include a way to close (cancel button, backdrop click).
-
Keep toasts brief - Short messages that don’t require action.
-
Position context menus near cursor - Use event coordinates for natural placement.
-
Limit overlay nesting - Avoid opening overlays from within overlays.
Custom State Machines
For complex interactions beyond hover/press, define custom state types with the StateTransitions trait.
Defining Custom States
#![allow(unused)]
fn main() {
use blinc_layout::stateful::StateTransitions;
use blinc_core::events::event_types::*;
#[derive(Clone, Copy, PartialEq, Eq, Hash, Default)]
enum PlayerState {
#[default]
Stopped,
Playing,
Paused,
}
impl StateTransitions for PlayerState {
fn on_event(&self, event: u32) -> Option<Self> {
match (self, event) {
// Click cycles through states
(PlayerState::Stopped, POINTER_UP) => Some(PlayerState::Playing),
(PlayerState::Playing, POINTER_UP) => Some(PlayerState::Paused),
(PlayerState::Paused, POINTER_UP) => Some(PlayerState::Playing),
_ => None,
}
}
}
}
Using Custom States
#![allow(unused)]
fn main() {
use blinc_layout::prelude::*;
fn player_button() -> impl ElementBuilder {
stateful::<PlayerState>()
.w(60.0)
.h(60.0)
.rounded_full()
.flex_center()
.on_state(|ctx| {
let bg = match ctx.state() {
PlayerState::Stopped => Color::rgba(0.3, 0.3, 0.35, 1.0),
PlayerState::Playing => Color::rgba(0.2, 0.8, 0.4, 1.0),
PlayerState::Paused => Color::rgba(0.9, 0.6, 0.2, 1.0),
};
div().bg(bg).child(text("▶").color(Color::WHITE))
})
}
}
Event Types
Available event types for state transitions:
#![allow(unused)]
fn main() {
use blinc_core::events::event_types::*;
POINTER_ENTER // Mouse enters element
POINTER_LEAVE // Mouse leaves element
POINTER_DOWN // Mouse button pressed
POINTER_UP // Mouse button released (click)
POINTER_MOVE // Mouse moved over element
KEY_DOWN // Keyboard key pressed
KEY_UP // Keyboard key released
TEXT_INPUT // Character typed
FOCUS // Element gained focus
BLUR // Element lost focus
SCROLL // Scroll event
DRAG // Drag motion
DRAG_END // Drag completed
}
Multi-Phase Interactions
Drag State Machine
#![allow(unused)]
fn main() {
#[derive(Clone, Copy, PartialEq, Eq, Hash, Default)]
enum DragPhase {
#[default]
Idle,
Hovering,
Pressing,
Dragging,
}
impl StateTransitions for DragPhase {
fn on_event(&self, event: u32) -> Option<Self> {
match (self, event) {
// Enter hover
(DragPhase::Idle, POINTER_ENTER) => Some(DragPhase::Hovering),
(DragPhase::Hovering, POINTER_LEAVE) => Some(DragPhase::Idle),
// Start press
(DragPhase::Hovering, POINTER_DOWN) => Some(DragPhase::Pressing),
// Transition to drag on move while pressed
(DragPhase::Pressing, DRAG) => Some(DragPhase::Dragging),
// Release
(DragPhase::Pressing, POINTER_UP) => Some(DragPhase::Hovering),
(DragPhase::Dragging, DRAG_END) => Some(DragPhase::Idle),
_ => None,
}
}
}
fn draggable_card() -> impl ElementBuilder {
stateful::<DragPhase>()
.w(120.0)
.h(80.0)
.rounded(8.0)
.on_state(|ctx| {
let (bg, cursor) = match ctx.state() {
DragPhase::Idle => (Color::BLUE, "default"),
DragPhase::Hovering => (Color::CYAN, "grab"),
DragPhase::Pressing => (Color::YELLOW, "grabbing"),
DragPhase::Dragging => (Color::GREEN, "grabbing"),
};
div().bg(bg).cursor(cursor)
})
}
}
Focus State Machine
#![allow(unused)]
fn main() {
#[derive(Clone, Copy, PartialEq, Eq, Hash, Default)]
enum InputFocus {
#[default]
Idle,
Hovered,
Focused,
FocusedHovered,
}
impl StateTransitions for InputFocus {
fn on_event(&self, event: u32) -> Option<Self> {
match (self, event) {
// Hover transitions
(InputFocus::Idle, POINTER_ENTER) => Some(InputFocus::Hovered),
(InputFocus::Hovered, POINTER_LEAVE) => Some(InputFocus::Idle),
(InputFocus::Focused, POINTER_ENTER) => Some(InputFocus::FocusedHovered),
(InputFocus::FocusedHovered, POINTER_LEAVE) => Some(InputFocus::Focused),
// Focus transitions
(InputFocus::Idle, FOCUS) => Some(InputFocus::Focused),
(InputFocus::Hovered, FOCUS) => Some(InputFocus::FocusedHovered),
(InputFocus::Hovered, POINTER_UP) => Some(InputFocus::FocusedHovered),
(InputFocus::Focused, BLUR) => Some(InputFocus::Idle),
(InputFocus::FocusedHovered, BLUR) => Some(InputFocus::Hovered),
_ => None,
}
}
}
fn focusable_input() -> impl ElementBuilder {
stateful::<InputFocus>()
.w(200.0)
.h(40.0)
.rounded(4.0)
.on_state(|ctx| {
let (border_color, border_width) = match ctx.state() {
InputFocus::Idle => (Color::GRAY, 1.0),
InputFocus::Hovered => (Color::LIGHT_GRAY, 1.0),
InputFocus::Focused => (Color::BLUE, 2.0),
InputFocus::FocusedHovered => (Color::BLUE, 2.0),
};
div().border(border_width, border_color)
})
}
}
Combining with External State
Use .deps() to combine state machine transitions with external signals:
#![allow(unused)]
fn main() {
fn smart_button() -> impl ElementBuilder {
let enabled = use_state_keyed("enabled", || true);
stateful::<ButtonState>()
.px(16.0)
.py(8.0)
.rounded(8.0)
.deps([enabled.signal_id()])
.on_state(move |ctx| {
let is_enabled = enabled.get();
let bg = if !is_enabled {
Color::rgba(0.2, 0.2, 0.25, 0.5) // Disabled
} else {
match ctx.state() {
ButtonState::Idle => Color::rgba(0.3, 0.5, 0.9, 1.0),
ButtonState::Hovered => Color::rgba(0.4, 0.6, 1.0, 1.0),
ButtonState::Pressed => Color::rgba(0.2, 0.4, 0.8, 1.0),
_ => Color::rgba(0.3, 0.5, 0.9, 1.0),
}
};
div().bg(bg).child(text("Submit").color(Color::WHITE))
})
}
}
Accessing Dependencies via Context
Use ctx.dep() for cleaner dependency access:
#![allow(unused)]
fn main() {
fn counter_button(count: State<i32>) -> impl ElementBuilder {
stateful::<ButtonState>()
.deps([count.signal_id()])
.on_state(|ctx| {
// Access by index - no need to capture in closure
let value: i32 = ctx.dep(0).unwrap_or_default();
// Or get a State handle for reading/writing
if let Some(count_state) = ctx.dep_as_state::<i32>(0) {
// count_state.set(value + 1);
}
let bg = match ctx.state() {
ButtonState::Hovered => Color::CYAN,
_ => Color::BLUE,
};
div()
.bg(bg)
.child(text(&format!("Count: {}", value)))
})
}
}
Using Scoped State
StateContext provides scoped signals and animated values:
#![allow(unused)]
fn main() {
fn interactive_counter() -> impl ElementBuilder {
stateful::<ButtonState>()
.on_state(|ctx| {
// Scoped signal - persists across rebuilds
let clicks = ctx.use_signal("clicks", || 0);
// Scoped animated value with spring physics
let scale = ctx.use_animated_value("scale", 1.0);
// Animate based on state
match ctx.state() {
ButtonState::Pressed => {
scale.lock().unwrap().set_target(0.95);
}
_ => {
scale.lock().unwrap().set_target(1.0);
}
}
let s = scale.lock().unwrap().get();
div()
.transform(Transform::scale(s, s))
.child(text(&format!("Clicks: {}", clicks.get())))
.on_click(move |_| {
clicks.update(|n| n + 1);
})
})
}
}
State Debugging
Log state transitions for debugging:
#![allow(unused)]
fn main() {
impl StateTransitions for MyState {
fn on_event(&self, event: u32) -> Option<Self> {
let next = match (self, event) {
// ... transitions ...
_ => None,
};
if let Some(ref new_state) = next {
println!("State: {:?} -> {:?} (event: {})", self, new_state, event);
}
next
}
}
}
Setting Initial State
Use .initial() when you need a non-default starting state:
#![allow(unused)]
fn main() {
fn initially_disabled_button(disabled: bool) -> impl ElementBuilder {
stateful::<ButtonState>()
.initial(if disabled { ButtonState::Disabled } else { ButtonState::Idle })
.on_state(|ctx| {
let bg = match ctx.state() {
ButtonState::Disabled => Color::GRAY,
ButtonState::Idle => Color::BLUE,
ButtonState::Hovered => Color::CYAN,
ButtonState::Pressed => Color::DARK_BLUE,
};
div().bg(bg)
})
}
}
NoState for Dependency-Only Containers
When you only need dependency tracking without state transitions:
#![allow(unused)]
fn main() {
fn data_display(data: State<Vec<String>>) -> impl ElementBuilder {
stateful::<NoState>()
.deps([data.signal_id()])
.on_state(|ctx| {
// Access data via context
let items: Vec<String> = ctx.dep(0).unwrap_or_default();
div()
.flex_col()
.gap(4.0)
.children(items.iter().map(|item| {
div().child(text(item))
}))
})
}
}
Best Practices
-
Keep states minimal - Only include states you need to distinguish visually.
-
Handle all paths - Consider every possible event in each state.
-
Use descriptive names - State names should clearly indicate the UI appearance.
-
Return None for no-ops - If an event doesn’t cause a transition, return
None. -
Test transitions - Verify all state paths work as expected.
-
Use
.deps()for external dependencies - When combining with signals. -
Use
ctx.dep()over closures - Cleaner access to dependency values. -
Implement Default - Mark the default state with
#[default]attribute. -
Use scoped signals -
ctx.use_signal()for state local to the stateful. -
Use animated values -
ctx.use_animated_value()for smooth transitions.
Performance Tips
Blinc is designed for high performance, but following these guidelines ensures your UI stays smooth.
Use Stateful for Visual States
Do: Use stateful(handle) for hover, press, and focus effects:
#![allow(unused)]
fn main() {
use blinc_layout::stateful::stateful;
fn hover_button(ctx: &WindowedContext) -> impl ElementBuilder {
let handle = ctx.use_state(ButtonState::Idle);
stateful(handle)
.px(16.0)
.py(8.0)
.rounded(8.0)
.on_state(|state, div| {
let bg = match state {
ButtonState::Idle => Color::RED,
ButtonState::Hovered => Color::BLUE,
_ => Color::RED,
};
div.set_bg(bg);
})
.child(text("Hover me").color(Color::WHITE))
}
}
Don’t: Use if-else or signals for visual-only state changes:
#![allow(unused)]
fn main() {
// AVOID - causes full tree rebuild on every hover
let is_hovered = ctx.use_signal(false);
div()
.on_hover_enter(move |_| ctx.set(is_hovered, true))
.on_hover_leave(move |_| ctx.set(is_hovered, false))
.bg(if ctx.get(is_hovered).unwrap_or(false) {
Color::BLUE
} else {
Color::RED
})
}
The stateful(handle) pattern only updates the affected element, while signals with if-else rebuild the entire UI tree.
Minimize Signal Updates
Signals trigger UI rebuilds. Batch related updates:
#![allow(unused)]
fn main() {
// Good - single rebuild
ctx.batch(|g| {
g.set(x, 10);
g.set(y, 20);
g.set(z, 30);
});
// Avoid - three rebuilds
ctx.set(x, 10);
ctx.set(y, 20);
ctx.set(z, 30);
}
Use Keyed State Appropriately
Keyed state persists across rebuilds. Use it for:
- Form input values
- Toggle states
- Selected items
Don’t overuse - each key adds memory overhead.
Efficient List Rendering
For large lists, consider:
- Virtualization - Only render visible items
- Stable keys - Use consistent identifiers for list items
- Memoization - Cache expensive computations
#![allow(unused)]
fn main() {
// For very long lists, wrap in scroll and limit rendered items
scroll()
.h(500.0)
.child(
div()
.flex_col()
.child(
visible_items.iter().map(|item| render_item(item))
)
)
}
Canvas Optimization
For custom drawing:
- Minimize state reads - Read animated values once, not per-shape
- Use transforms - Push/pop transforms instead of recalculating positions
- Batch similar draws - Group shapes by color/brush
#![allow(unused)]
fn main() {
canvas(move |ctx, bounds| {
// Read once
let angle = timeline.lock().unwrap().get(entry_id).unwrap_or(0.0);
// Use transform for rotation
ctx.push_transform(Transform::rotate(angle));
// ... draw ...
ctx.pop_transform();
})
}
Animation Performance
- Use appropriate spring stiffness - Stiffer springs settle faster
- Limit simultaneous animations - Too many can cause jank
- Use timelines for loops - More efficient than many spring values
#![allow(unused)]
fn main() {
// Good - single timeline with multiple entries
let timeline = ctx.use_animated_timeline();
let (x, y, scale) = timeline.lock().unwrap().configure(|t| {
(t.add(0, 1000, 0.0, 100.0),
t.add(0, 1000, 0.0, 100.0),
t.add(0, 500, 1.0, 1.5))
});
}
Memory Management
- Clone Arc, not data - Use
Arc::clone()for shared state - Drop unused state - Clean up keyed state when no longer needed
- Avoid closures capturing large data - Clone only what’s needed
#![allow(unused)]
fn main() {
// Good - clone the Arc, not the data
let data = Arc::clone(&shared_data);
// Avoid - captures entire struct
let large_struct = expensive_struct.clone();
div().on_click(move |_| use_struct(&large_struct))
}
Lazy Loading for Images
For applications with many images (galleries, feeds, chat), use lazy loading to defer loading until images are visible:
#![allow(unused)]
fn main() {
// Images in a scrollable gallery
scroll()
.h(600.0)
.child(
div()
.flex_row()
.flex_wrap()
.gap(8.0)
.child(
image_urls.iter().map(|url| {
img(*url)
.lazy() // Only loads when scrolled into view
.placeholder_color(Color::rgba(0.2, 0.2, 0.2, 1.0))
.w(150.0)
.h(150.0)
.cover()
})
)
)
}
Benefits:
- Reduced initial memory - Only visible images are loaded
- Faster startup - No waiting for off-screen images
- Automatic cleanup - LRU cache evicts old images
Emoji images (emoji() and emoji_sized()) are automatically lazy-loaded. The ~180MB system emoji font is only loaded when emoji characters actually appear on screen.
Debugging Performance
Enable tracing to identify bottlenecks:
#![allow(unused)]
fn main() {
tracing_subscriber::fmt()
.with_env_filter("blinc_layout=debug")
.init();
}
Look for:
- Frequent tree rebuilds
- Long frame times
- Excessive state updates
Summary
| Do | Don’t |
|---|---|
Use Stateful for hover/press | Use signals for visual-only changes |
| Batch signal updates | Update signals one at a time |
Use Arc::clone() | Clone large data into closures |
| Use timelines for loops | Create many spring values |
| Read animated values once | Read repeatedly in draw loops |
Architecture Overview
Blinc is a high-performance UI framework built from the ground up for GPU-accelerated rendering without virtual DOM overhead. This chapter explains how the major systems work together.
Design Philosophy
Blinc follows several key principles:
- Fine-grained Reactivity - Signal-based state management with automatic dependency tracking eliminates the need for virtual DOM diffing
- Layout as Separate Concern - Tree structure is independent from visual properties, enabling visual-only updates without layout recomputation
- GPU-First Rendering - SDF shaders provide resolution-independent, smooth rendering with glass/blur effects
- Incremental Updates - Hash-based diffing with change categories minimizes recomputation
- Background Thread Animations - Animation scheduler runs independently from the UI thread
System Architecture
┌─────────────────────────────────────────────────────────────┐
│ WindowedApp Event Loop (Platform abstraction) │
├─────────────────────────────────────────────────────────────┤
│ • Receives pointer, keyboard, lifecycle events │
│ • Routes through EventRouter -> StateMachines │
│ • Triggers reactive signal updates │
│ • Checks signal dependencies for rebuilds │
└─────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ ReactiveGraph & Stateful Element Updates │
├─────────────────────────────────────────────────────────────┤
│ • Signals change -> Effects run -> Rebuilds queued │
│ • Stateful elements transition -> Subtree rebuild queued │
│ • Diff algorithm determines change categories │
└─────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ RenderTree Update (Incremental) │
├─────────────────────────────────────────────────────────────┤
│ • incremental_update() compares hashes │
│ • VisualOnly: apply prop updates only │
│ • ChildrenChanged: rebuild subtrees + mark layout dirty │
└─────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ Layout Computation (Taffy Flexbox) │
├─────────────────────────────────────────────────────────────┤
│ • compute_layout() on dirty nodes only │
│ • Returns (x, y, width, height) for all nodes │
│ • Cached in LayoutTree │
└─────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ Animation Scheduler (Background Thread @ 120fps) │
├─────────────────────────────────────────────────────────────┤
│ • Ticks springs, keyframes, timelines │
│ • Stores current values in AnimatedValue │
│ • Sets needs_redraw flag, wakes main thread │
└─────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ GPU Rendering (DrawContext -> GpuRenderer) │
├─────────────────────────────────────────────────────────────┤
│ • RenderTree traversal, samples animation values │
│ • Emits SDF primitives to batches │
│ • Multi-pass rendering: Background -> Glass -> Foreground │
└─────────────────────────────────────────────────────────────┘
Core Crates
| Crate | Purpose |
|---|---|
blinc_core | Reactive signals, FSM, core types, event system |
blinc_layout | Element builders, Taffy integration, diff system, stateful elements |
blinc_animation | Spring physics, keyframe timelines, animation scheduler |
blinc_gpu | wgpu renderer, SDF shaders, glass effects, text rendering |
blinc_text | Font loading, glyph shaping, text atlas |
blinc_app | WindowedApp, render context, platform integration |
Why No Virtual DOM?
Traditional frameworks (React, Vue) use a virtual DOM to diff the entire component tree on every state change. This has overhead:
- Creating VDOM objects for every render
- Diffing the full tree to find changes
- Patching the real DOM with changes
Blinc avoids this with:
- Fine-grained signals - Only dependent code re-runs when state changes
- Stateful elements - UI state managed at the element level, not rebuilt from scratch
- Hash-based diffing - Quick equality checks without deep comparison
- Change categories - Visual vs layout vs structural changes handled differently
The result: updates proportional to what changed, not to tree size.
Chapter Contents
- GPU Rendering - SDF primitives, glass effects, text rendering
- Reactive State - Signal system, dependency tracking, effects
- Layout & Diff - Taffy integration, incremental updates
- Animation - Spring physics, timelines, scheduler
- Stateful Elements - FSM-driven interactive widgets
GPU Rendering
Blinc uses GPU-accelerated rendering via wgpu with Signed Distance Field (SDF) shaders for smooth, resolution-independent UI primitives.
Signed Distance Fields
An SDF shader computes the signed distance from each pixel to the geometry’s edge:
- Negative distance: pixel is inside the shape
- Positive distance: pixel is outside the shape
- Zero: pixel is exactly on the edge
This enables:
- Smooth anti-aliasing at any scale
- Per-corner rounded rectangles
- Soft shadows with Gaussian blur
- Sharp text at any zoom level
GPU Primitive Structure
All UI elements are converted to GpuPrimitive structs (192 bytes each):
#![allow(unused)]
fn main() {
struct GpuPrimitive {
// Geometry
bounds: [f32; 4], // x, y, width, height
corner_radii: [f32; 4], // top-left, top-right, bottom-right, bottom-left
// Fill
fill_type: FillType, // Solid, LinearGradient, RadialGradient
fill_colors: [Color; 4], // gradient stops or solid color
// Border
border_width: f32,
border_color: Color,
// Shadow
shadow_offset: [f32; 2],
shadow_blur: f32,
shadow_color: Color,
// Clipping
clip_bounds: [f32; 4],
clip_radii: [f32; 4],
}
}
Primitive Types
| Type | Description |
|---|---|
Rect | Rounded rectangle with per-corner radius |
Circle | Perfect circle |
Ellipse | Axis-aligned ellipse |
Shadow | Drop shadow with Gaussian blur |
InnerShadow | Inset shadow |
Text | Glyph sampled from texture atlas |
Fill Types
| Type | Description |
|---|---|
Solid | Single color fill |
LinearGradient | Gradient between two points |
RadialGradient | Gradient radiating from center |
Rendering Pipeline
Rendering happens in multiple passes for proper layering:
1. Background Pass (non-glass elements)
└── SDF shader for rects, circles, shadows
2. Glass Pass (frosted glass elements)
└── Glass shader samples backbuffer + blur
3. Foreground Pass (text, overlays)
└── Text shader with glyph atlases
└── SDF shader for overlays
4. Composite Pass
└── Blend layers + MSAA resolve
SDF Shader
The main SDF shader handles rectangles with:
// Signed distance to rounded rectangle
fn sdf_rounded_rect(p: vec2f, size: vec2f, radii: vec4f) -> f32 {
// Select corner radius based on quadrant
let radius = select_corner_radius(p, radii);
// Compute distance to rounded corner
let q = abs(p) - size + radius;
return min(max(q.x, q.y), 0.0) + length(max(q, vec2f(0.0))) - radius;
}
Anti-aliasing uses the SDF gradient:
// Smooth alpha based on distance
let alpha = 1.0 - smoothstep(-0.5, 0.5, distance);
Shadow Rendering
Soft shadows use Gaussian blur approximated with the error function:
fn shadow_alpha(distance: f32, blur_radius: f32) -> f32 {
// Error function approximation for Gaussian
let t = distance / (blur_radius * 0.5);
return 0.5 * (1.0 - erf(t));
}
Glass Effects
Blinc implements Apple-style frosted glass (vibrancy) with backdrop blur:
Glass Types
| Type | Blur | Saturation | Use Case |
|---|---|---|---|
UltraThin | Light | High | Subtle overlays |
Thin | Medium | Medium | Sidebars |
Regular | Standard | Standard | Modals, cards |
Thick | Heavy | Low | Headers |
Chrome | Very heavy | Low | Window chrome |
Glass Shader
The glass shader:
- Samples backbuffer - Reads rendered content behind the glass
- Applies Gaussian blur - Multi-tap sampling with weights
- Adjusts saturation - Controls color vibrancy
- Adds tint color - Overlays glass tint
- Applies noise grain - Frosted texture effect
- Rim bending - Light refraction at edges
// Simplified glass computation
fn glass_color(uv: vec2f, glass_type: u32) -> vec4f {
// Sample and blur background
var color = blur_sample(backbuffer, uv, blur_radius);
// Adjust saturation
color = saturate_color(color, saturation);
// Apply tint
color = mix(color, tint_color, tint_strength);
// Add noise grain
color += noise(uv) * grain_amount;
return color;
}
Three-Layer Rendering
Glass requires separating background from foreground:
┌─────────────────────────────────┐
│ Foreground Layer │ Text, icons on glass
├─────────────────────────────────┤
│ Glass Layer │ Frosted glass with blur
├─────────────────────────────────┤
│ Background Layer │ Content behind glass
└─────────────────────────────────┘
The renderer:
- Renders background to backbuffer texture
- Renders glass elements sampling the backbuffer
- Renders foreground elements on top
- Composites all layers
Text Rendering
Text uses a separate glyph atlas system:
- Font Loading - Parses TTF/OTF via rustybuzz
- Glyph Shaping - HarfBuzz-compatible shaping for complex scripts
- Atlas Generation - Rasterizes glyphs to texture atlas
- SDF Text - Stores distance field for each glyph
- Rendering - Samples atlas with color spans
#![allow(unused)]
fn main() {
// Text rendering produces glyph primitives
for glyph in shaped_text.glyphs() {
emit_text_primitive(GpuPrimitive {
primitive_type: PrimitiveType::Text,
bounds: glyph.bounds,
texture_coords: glyph.atlas_uv,
color: text_color,
// ...
});
}
}
Batching & Instancing
Primitives are batched by type and rendered with GPU instancing:
#![allow(unused)]
fn main() {
// All rects in one draw call
batch.add_primitive(rect1);
batch.add_primitive(rect2);
batch.add_primitive(rect3);
// Single instanced draw call for all rects
}
Benefits:
- Minimal CPU-GPU communication
- Efficient use of GPU parallelism
- Scales to thousands of primitives
MSAA Support
Blinc supports multi-sample anti-aliasing:
| Sample Count | Quality | Performance |
|---|---|---|
| 1x | Baseline | Fastest |
| 2x | Improved edges | Slight cost |
| 4x | Good quality | Moderate cost |
| 8x | High quality | Higher cost |
MSAA is resolved in the final composite pass.
DrawContext Trait
The bridge between layout and GPU is the DrawContext trait:
#![allow(unused)]
fn main() {
trait DrawContext {
fn draw_rect(&mut self, bounds: Rect, props: &RenderProps);
fn draw_text(&mut self, text: &ShapedText, position: Point);
fn draw_shadow(&mut self, bounds: Rect, shadow: &Shadow);
fn push_clip(&mut self, bounds: Rect, radii: [f32; 4]);
fn pop_clip(&mut self);
fn push_transform(&mut self, transform: Transform);
fn pop_transform(&mut self);
}
}
The RenderTree traverses nodes and calls DrawContext methods, which accumulate GPU primitives for the render passes.
Reactive State System
Blinc implements a push-pull hybrid reactive system for fine-grained state management without virtual DOM overhead. This is inspired by modern reactive frameworks like Leptos and SolidJS.
Core Concepts
Signals
A Signal<T> is a reactive container for a value. When the value changes, all dependent computations automatically update.
#![allow(unused)]
fn main() {
// Create a signal
let count = ctx.use_state_keyed("count", || 0i32);
// Read the current value
let value = count.get();
// Update the value
count.set(5);
count.update(|v| v + 1);
}
Signal IDs
Signals are identified by SignalId, a cheap-to-copy handle:
#![allow(unused)]
fn main() {
// Get the signal's ID for dependency tracking
let id = count.signal_id();
}
Automatic Dependency Tracking
When code accesses a signal’s value, the dependency is automatically recorded:
#![allow(unused)]
fn main() {
// Stateful element with signal dependency
stateful(handle)
.deps(&[count.signal_id()]) // Declare dependency
.on_state(move |state, div| {
// Reading count.get() here is tracked
let value = count.get();
div.set_bg(color_for_value(value));
})
}
When count changes, only elements depending on it re-run their callbacks.
ReactiveGraph Internals
The ReactiveGraph manages all reactive state:
#![allow(unused)]
fn main() {
struct ReactiveGraph {
signals: SlotMap<SignalId, SignalNode>,
deriveds: SlotMap<DerivedId, DerivedNode>,
effects: SlotMap<EffectId, EffectNode>,
pending_effects: Vec<EffectId>,
batch_depth: u32,
}
}
Data Structures
| Type | Purpose |
|---|---|
SignalNode | Stores value + list of subscribers |
DerivedNode | Cached computed value + dirty flag |
EffectNode | Side-effect function + dependencies |
Subscription Flow
Signal.set(new_value)
│
├── Mark all subscribers dirty
│
├── Propagate to derived values
│
└── Queue effects for execution
Derived Values
Derived values compute from other signals and cache the result:
#![allow(unused)]
fn main() {
// Conceptual - derived values
let doubled = derived(|| count.get() * 2);
// Value is cached until count changes
let value = doubled.get(); // Computed once
let again = doubled.get(); // Returns cached value
}
Lazy Evaluation
Derived values only compute when:
- First accessed after creation
- Accessed after a dependency changed
- Their value is explicitly needed
This prevents wasted computation for unused values.
Effects
Effects are side-effects that run when dependencies change:
#![allow(unused)]
fn main() {
// Conceptual - effects
effect(|| {
let value = count.get(); // Tracks dependency on count
println!("Count changed to {}", value);
});
}
Effects are:
- Queued when dependencies change
- Executed after the current batch completes
- Run in topological order (respecting dependency depth)
Batching
Multiple signal updates can be batched to prevent redundant recomputation:
#![allow(unused)]
fn main() {
// Without batching: 3 separate updates, 3 effect runs
count.set(1);
name.set("Alice");
enabled.set(true);
// With batching: 1 combined update, 1 effect run
ctx.batch(|g| {
g.set(count, 1);
g.set(name, "Alice");
g.set(enabled, true);
});
}
How Batching Works
batch_start()increments batch depth counter- Signal updates mark subscribers dirty but don’t run effects
batch_end()decrements counter- When counter reaches 0, all pending effects execute
Integration with Stateful Elements
The reactive system integrates with stateful elements via .deps():
#![allow(unused)]
fn main() {
fn counter_display(ctx: &WindowedContext, count: State<i32>) -> impl ElementBuilder {
let handle = ctx.use_state(ButtonState::Idle);
stateful(handle)
// Declare signal dependencies
.deps(&[count.signal_id()])
.on_state(move |_state, container| {
// This callback re-runs when count changes
let current = count.get();
container.merge(
div().child(text(&format!("{}", current)).color(Color::WHITE))
);
})
}
}
Dependency Registry
The system maintains a registry of signal dependencies:
#![allow(unused)]
fn main() {
// Internal tracking
struct DependencyEntry {
signal_ids: Vec<SignalId>,
node_id: LayoutNodeId,
refresh_callback: Box<dyn Fn()>,
}
}
When signals change, the registry triggers rebuilds for dependent nodes.
Performance Characteristics
O(1) Signal Access
Reading a signal is a simple memory lookup:
#![allow(unused)]
fn main() {
fn get(&self) -> T {
self.value.clone() // Direct access, no computation
}
}
O(subscribers) Propagation
Updates only touch direct subscribers:
#![allow(unused)]
fn main() {
fn set(&mut self, value: T) {
self.value = value;
for subscriber in &self.subscribers {
subscriber.mark_dirty();
}
}
}
Minimal Allocations
SignalIdis a 64-bit handle (Copy)- Subscriber lists use
SmallVec<[_; 4]>(inline for small counts) - SlotMap provides dense storage without gaps
Comparison to Virtual DOM
| Aspect | Virtual DOM | Blinc Reactive |
|---|---|---|
| State change | Rebuild entire component | Update only affected nodes |
| Diffing | O(tree size) | O(1) per signal |
| Memory | VDOM objects per render | Fixed signal storage |
| Dependency tracking | Manual (useEffect deps) | Automatic |
Best Practices
-
Use keyed state for persistence -
use_state_keyed("key", || value)survives rebuilds -
Batch related updates - Group multiple signal changes to avoid redundant work
-
Declare dependencies explicitly - Use
.deps()for stateful elements that read signals -
Prefer stateful for visual changes - Use stateful elements instead of signals for hover/press effects
-
Keep signals granular - Fine-grained signals enable more precise updates
Layout & Diff System
Blinc separates layout computation from visual rendering, enabling incremental updates without full tree rebuilds.
Layout System
Taffy Integration
Blinc uses Taffy for flexbox layout computation. Taffy is a high-performance layout engine that implements the CSS Flexbox specification.
#![allow(unused)]
fn main() {
// LayoutTree wraps Taffy
struct LayoutTree {
taffy: TaffyTree,
node_map: HashMap<LayoutNodeId, TaffyNodeId>,
reverse_map: HashMap<TaffyNodeId, LayoutNodeId>,
}
}
Layout Properties vs Visual Properties
Layout and visual properties are handled separately:
| Layout Properties | Visual Properties |
|---|---|
width, height | background |
padding, margin | border_color |
flex_direction | shadow |
justify_content | opacity |
align_items | transform |
gap | rounded |
This separation allows visual-only updates without layout recomputation.
RenderTree Structure
#![allow(unused)]
fn main() {
struct RenderTree {
layout_tree: LayoutTree, // Taffy wrapper
render_props: HashMap<NodeId, RenderProps>, // Visual properties
dirty_nodes: HashSet<NodeId>, // Nodes needing update
scroll_state: HashMap<NodeId, ScrollState>,
motion_bindings: HashMap<NodeId, MotionBinding>,
}
}
Layout Computation
Layout is computed on-demand when the tree structure changes:
#![allow(unused)]
fn main() {
// Compute layout for the tree
render_tree.compute_layout(root_id, AvailableSpace {
width: window_width,
height: window_height,
});
// Get computed bounds for a node
let layout = render_tree.get_layout(node_id);
// Returns: Layout { x, y, width, height }
}
Diff System
The diff system determines the minimum changes needed when UI structure updates.
DivHash - Content-Based Identity
Every element has a hash computed from its properties:
#![allow(unused)]
fn main() {
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
struct DivHash(u64);
impl Div {
// Hash of this element only (excludes children)
fn compute_hash(&self) -> DivHash {
let mut hasher = DefaultHasher::new();
self.width.hash(&mut hasher);
self.height.hash(&mut hasher);
self.background.hash(&mut hasher);
// ... all properties
DivHash(hasher.finish())
}
// Hash including entire subtree
fn compute_tree_hash(&self) -> DivHash {
let mut hasher = DefaultHasher::new();
self.compute_hash().hash(&mut hasher);
for child in &self.children {
child.compute_tree_hash().hash(&mut hasher);
}
DivHash(hasher.finish())
}
}
}
Change Categories
Changes are classified to determine the update strategy:
#![allow(unused)]
fn main() {
struct ChangeCategory {
layout: bool, // Requires layout recomputation
visual: bool, // Only visual properties changed
children: bool, // Children added/removed/reordered
handlers: bool, // Event handlers changed
}
}
| Category | Example Changes | Action |
|---|---|---|
| Visual only | bg, opacity, shadow | Update RenderProps |
| Layout | width, padding, gap | Recompute layout |
| Children | Add/remove child | Rebuild subtree |
| Handlers | Event callback changed | Update handlers |
Child Diffing Algorithm
When children change, the diff algorithm matches old and new children:
#![allow(unused)]
fn main() {
enum ChildDiff {
Unchanged { index: usize }, // Same content, same position
Moved { old_idx: usize, new_idx: usize }, // Same content, moved
Modified { old_idx: usize, new_idx: usize, diff: Box<DiffResult> },
Added { index: usize }, // New child
Removed { index: usize }, // Old child gone
}
}
Matching Strategy
- Compute hashes for all old and new children
- Build hash map of old children by hash
- Match new children to old by hash lookup
- Detect moves when hash matches at different position
- Classify remaining as added or removed
- Merge same-position changes as modifications
#![allow(unused)]
fn main() {
fn diff_children(old: &[Div], new: &[Div]) -> Vec<ChildDiff> {
let old_hashes: Vec<_> = old.iter().map(|c| c.compute_tree_hash()).collect();
let new_hashes: Vec<_> = new.iter().map(|c| c.compute_tree_hash()).collect();
let mut old_by_hash: HashMap<DivHash, usize> = old_hashes
.iter()
.enumerate()
.map(|(i, h)| (*h, i))
.collect();
let mut diffs = Vec::new();
for (new_idx, new_hash) in new_hashes.iter().enumerate() {
if let Some(old_idx) = old_by_hash.remove(new_hash) {
if old_idx == new_idx {
diffs.push(ChildDiff::Unchanged { index: new_idx });
} else {
diffs.push(ChildDiff::Moved { old_idx, new_idx });
}
} else {
diffs.push(ChildDiff::Added { index: new_idx });
}
}
// Remaining old children were removed
for (old_idx, _) in old_by_hash {
diffs.push(ChildDiff::Removed { index: old_idx });
}
diffs
}
}
Incremental Updates
Update Result
The incremental update process returns what changed:
#![allow(unused)]
fn main() {
enum UpdateResult {
NoChanges, // Nothing to do
VisualOnly(Vec<PropUpdate>), // Apply prop updates only
LayoutChanged, // Props + recompute layout
ChildrenChanged, // Rebuild subtrees + layout
}
}
Reconciliation
The reconcile function determines actions from diffs:
#![allow(unused)]
fn main() {
struct ReconcileActions {
prop_updates: Vec<(NodeId, RenderProps)>, // Visual updates
subtree_rebuild_ids: Vec<NodeId>, // Nodes to rebuild
needs_layout: bool, // Layout recomputation needed
}
fn reconcile(old: &Div, new: &Div) -> ReconcileActions {
let changes = categorize_changes(old, new);
let mut actions = ReconcileActions::default();
if changes.visual && !changes.layout && !changes.children {
// Visual-only: just update props
actions.prop_updates.push((node_id, new.to_render_props()));
}
if changes.layout {
actions.needs_layout = true;
}
if changes.children {
actions.subtree_rebuild_ids.push(node_id);
actions.needs_layout = true;
}
actions
}
}
Update Flow
incremental_update(root, new_element)
│
├── Compare hashes: old_tree_hash vs new_tree_hash
│ └── Same? Return NoChanges
│
├── Compare element hashes: old_hash vs new_hash
│ └── Same? Children might have changed
│
├── Categorize changes
│ ├── Visual only? Queue prop updates
│ ├── Layout changed? Mark layout dirty
│ └── Children changed? Diff children recursively
│
└── Return UpdateResult
Pending Update Queues
The system uses global queues for deferred updates:
#![allow(unused)]
fn main() {
// Global pending updates (thread-local)
static PENDING_PROP_UPDATES: RefCell<Vec<(NodeId, RenderProps)>>;
static PENDING_SUBTREE_REBUILDS: RefCell<Vec<NodeId>>;
static NEEDS_REDRAW: AtomicBool;
}
Queue Processing
The windowed app processes queues each frame:
#![allow(unused)]
fn main() {
fn process_pending_updates(&mut self) {
// Apply prop updates (no layout needed)
for (node_id, props) in drain_prop_updates() {
self.render_tree.update_props(node_id, props);
}
// Rebuild dirty subtrees
for node_id in drain_subtree_rebuilds() {
self.rebuild_subtree(node_id);
}
// Recompute layout if needed
if self.layout_dirty {
self.render_tree.compute_layout(self.root_id, self.available_space);
self.layout_dirty = false;
}
// Trigger redraw if visual changes occurred
if NEEDS_REDRAW.swap(false, Ordering::SeqCst) {
self.request_redraw();
}
}
}
Performance Benefits
Hash-Based Comparison
- O(1) per element to compute hash
- O(1) equality check via hash comparison
- No deep property-by-property comparison needed
Child Matching
- O(n) to build hash map
- O(n) to match children
- Detects moves without position-based assumptions
Minimal Recomputation
| Scenario | What Runs |
|---|---|
| Hover color change | Update 1 RenderProps |
| Text content change | Rebuild 1 text node |
| Add item to list | Insert node, layout affected subtree |
| Reorder list | Move nodes, minimal layout |
Layout Caching
- Layout only recomputes when structure/dimensions change
- Visual-only changes skip layout entirely
- Taffy caches intermediate results
Animation System
Blinc provides a multi-layered animation system with physics-based springs and timed keyframe animations.
Spring Physics
Springs are the foundation of Blinc’s animation system, providing natural, interruptible motion.
Spring Model
A spring follows Hooke’s law with damping:
Force = -k * (position - target) - d * velocity
where:
k = stiffness (spring tightness)
d = damping (friction)
Spring Structure
#![allow(unused)]
fn main() {
struct Spring {
value: f32, // Current position
velocity: f32, // Current velocity
target: f32, // Destination
config: SpringConfig,
}
struct SpringConfig {
stiffness: f32, // Spring constant (k)
damping: f32, // Damping coefficient (d)
mass: f32, // Virtual mass
}
}
RK4 Integration
Blinc uses 4th-order Runge-Kutta (RK4) integration for stability:
#![allow(unused)]
fn main() {
fn step(&mut self, dt: f32) {
// RK4 provides stable integration even with large timesteps
let k1 = self.acceleration(self.value, self.velocity);
let k2 = self.acceleration(
self.value + self.velocity * dt * 0.5,
self.velocity + k1 * dt * 0.5
);
let k3 = self.acceleration(
self.value + (self.velocity + k2 * dt * 0.5) * dt * 0.5,
self.velocity + k2 * dt * 0.5
);
let k4 = self.acceleration(
self.value + (self.velocity + k3 * dt) * dt,
self.velocity + k3 * dt
);
self.velocity += (k1 + 2.0 * k2 + 2.0 * k3 + k4) * dt / 6.0;
self.value += self.velocity * dt;
}
}
Spring Presets
| Preset | Stiffness | Damping | Character |
|---|---|---|---|
stiff() | 400 | 30 | Fast, minimal overshoot |
snappy() | 300 | 20 | Quick with slight bounce |
gentle() | 120 | 14 | Soft, slower motion |
wobbly() | 180 | 12 | Bouncy, playful |
molasses() | 50 | 20 | Very slow, heavy |
Settling Detection
A spring is considered settled when:
#![allow(unused)]
fn main() {
fn is_settled(&self) -> bool {
let position_settled = (self.value - self.target).abs() < EPSILON;
let velocity_settled = self.velocity.abs() < VELOCITY_EPSILON;
position_settled && velocity_settled
}
}
AnimatedValue
AnimatedValue wraps a spring for easy use in components:
#![allow(unused)]
fn main() {
// Create an animated value
let scale = ctx.use_animated_value(1.0, SpringConfig::snappy());
// Read current value
let current = scale.lock().unwrap().get();
// Set new target (animates to it)
scale.lock().unwrap().set_target(1.2);
// Set immediately (no animation)
scale.lock().unwrap().set(1.0);
}
SharedAnimatedValue
For use across closures, values are wrapped in Arc<Mutex<_>>:
#![allow(unused)]
fn main() {
let scale = ctx.use_animated_value(1.0, SpringConfig::snappy());
// Clone Arc for closure
let hover_scale = Arc::clone(&scale);
motion()
.scale(scale.lock().unwrap().get())
.on_hover_enter(move |_| {
hover_scale.lock().unwrap().set_target(1.1);
})
}
Keyframe Animations
For time-based animations with specific durations:
Keyframe Structure
#![allow(unused)]
fn main() {
struct Keyframe {
time: f32, // Time in animation (0.0 - 1.0)
value: f32, // Value at this keyframe
easing: EasingFn, // Interpolation to next keyframe
}
}
Easing Functions
| Easing | Description |
|---|---|
linear | Constant speed |
ease_in | Start slow, end fast |
ease_out | Start fast, end slow |
ease_in_out | Slow at both ends |
ease_in_quad | Quadratic ease in |
ease_out_cubic | Cubic ease out |
ease_in_out_elastic | Elastic bounce |
Animation Fill Modes
| Mode | Description |
|---|---|
None | Revert after animation |
Forwards | Keep final value |
Backwards | Apply initial before start |
Both | Forwards + Backwards |
Timelines
Timelines coordinate multiple animations:
#![allow(unused)]
fn main() {
let timeline = ctx.use_animated_timeline();
let entry_id = timeline.lock().unwrap().configure(|t| {
// Add animation entries
let rotation_id = t.add(
0, // start_ms
1000, // duration_ms
0.0, // from
360.0 // to
);
// Configure looping
t.set_loop(-1); // -1 = infinite loop
// Start the timeline
t.start();
rotation_id
});
}
Timeline Features
- Stagger - Delay between child animations
- Loop - Repeat animations
- Reverse - Play backwards
- Alternate - Ping-pong direction
Animation Scheduler
A background thread ticks all animations at 120fps:
#![allow(unused)]
fn main() {
struct AnimationScheduler {
springs: Vec<SharedAnimatedValue>,
timelines: Vec<SharedAnimatedTimeline>,
running: AtomicBool,
needs_redraw: Arc<AtomicBool>,
wake_callback: Box<dyn Fn() + Send>,
}
}
Scheduler Loop
#![allow(unused)]
fn main() {
fn run(&self) {
let frame_duration = Duration::from_secs_f64(1.0 / 120.0);
while self.running.load(Ordering::SeqCst) {
let start = Instant::now();
// Tick all springs
for spring in &self.springs {
spring.lock().unwrap().step(frame_duration.as_secs_f32());
}
// Tick all timelines
for timeline in &self.timelines {
timeline.lock().unwrap().tick(frame_duration);
}
// If any animation is active, request redraw
if self.has_active_animations() {
self.needs_redraw.store(true, Ordering::SeqCst);
(self.wake_callback)(); // Wake the main thread
}
// Sleep for remaining frame time
let elapsed = start.elapsed();
if elapsed < frame_duration {
thread::sleep(frame_duration - elapsed);
}
}
}
}
Benefits of Background Thread
- Consistent timing - Animations run at 120fps regardless of main thread
- Survives focus loss - Continues when window loses focus
- Non-blocking - Doesn’t block UI event processing
- Battery efficient - Only runs when animations are active
Motion Container
motion() binds animations to elements:
#![allow(unused)]
fn main() {
motion()
.scale(scale.lock().unwrap().get()) // Read current value
.opacity(opacity.lock().unwrap().get())
.translate_y(y.lock().unwrap().get())
.child(content)
}
How Motion Works
- At build time: Reads current animation values
- Stores binding: Remembers which animated values to sample
- At render time: Samples current values from scheduler
- No rebuild needed: Animation updates don’t trigger tree rebuilds
Enter/Exit Animations
Motion also provides declarative enter/exit:
#![allow(unused)]
fn main() {
motion()
.fade_in(300) // Fade in over 300ms
.scale_in(300) // Scale from 0 to 1
.slide_in(SlideDirection::Right, 200) // Slide from right
.child(content)
}
Integration Points
With Stateful Elements
#![allow(unused)]
fn main() {
fn animated_button(ctx: &WindowedContext) -> impl ElementBuilder {
let scale = ctx.use_animated_value(1.0, SpringConfig::snappy());
let hover = Arc::clone(&scale);
let leave = Arc::clone(&scale);
motion()
.scale(scale.lock().unwrap().get())
.on_hover_enter(move |_| {
hover.lock().unwrap().set_target(1.05);
})
.on_hover_leave(move |_| {
leave.lock().unwrap().set_target(1.0);
})
.child(button_content())
}
}
With BlincComponent
#![allow(unused)]
fn main() {
#[derive(BlincComponent)]
struct ExpandableCard {
#[animation]
height: f32,
#[animation]
arrow_rotation: f32,
}
fn card(ctx: &WindowedContext) -> impl ElementBuilder {
let height = ExpandableCard::use_height(ctx, 60.0, SpringConfig::snappy());
let rotation = ExpandableCard::use_arrow_rotation(ctx, 0.0, SpringConfig::snappy());
motion()
.h(height.lock().unwrap().get())
.on_click(move |_| {
height.lock().unwrap().set_target(200.0);
rotation.lock().unwrap().set_target(180.0);
})
.child(card_content())
}
}
Performance Considerations
- Spring settling - Stopped springs don’t consume CPU
- Batched ticks - All animations tick together
- No allocations - Animation values are pre-allocated
- GPU transforms - Motion transforms are GPU-accelerated
- Minimal redraws - Only redraw when animations are active
Stateful Elements & FSM
Blinc uses Finite State Machines (FSM) to manage interactive UI state. This provides predictable state transitions for widgets like buttons, checkboxes, and text fields.
Finite State Machines
Core Concepts
An FSM defines:
- States: Discrete conditions the element can be in
- Events: Inputs that trigger transitions
- Transitions: Rules mapping (state, event) -> new_state
#![allow(unused)]
fn main() {
// State IDs and Event IDs are u32
type StateId = u32;
type EventId = u32;
struct Transition {
from_state: StateId,
event: EventId,
to_state: StateId,
guard: Option<Box<dyn Fn() -> bool>>, // Conditional transition
action: Option<Box<dyn Fn()>>, // Side effect
}
}
FSM Builder
#![allow(unused)]
fn main() {
let fsm = StateMachine::builder(initial_state)
.on(State::Idle, Event::PointerEnter, State::Hovered)
.on(State::Hovered, Event::PointerLeave, State::Idle)
.on(State::Hovered, Event::PointerDown, State::Pressed)
.on(State::Pressed, Event::PointerUp, State::Hovered)
.on_enter(State::Pressed, || {
println!("Button pressed!");
})
.build();
}
Entry/Exit Callbacks
#![allow(unused)]
fn main() {
.on_enter(state, || { /* called when entering state */ })
.on_exit(state, || { /* called when leaving state */ })
}
Guard Conditions
Transitions can be conditional:
#![allow(unused)]
fn main() {
.transition(
Transition::new(State::Idle, Event::Click, State::Active)
.with_guard(|| is_enabled())
)
}
StateTransitions Trait
For type-safe state definitions, implement StateTransitions:
#![allow(unused)]
fn main() {
use blinc_layout::stateful::StateTransitions;
use blinc_core::events::event_types::*;
#[derive(Clone, Copy, PartialEq, Eq, Hash, Default)]
enum ButtonState {
#[default]
Idle,
Hovered,
Pressed,
Disabled,
}
impl StateTransitions for ButtonState {
fn on_event(&self, event: u32) -> Option<Self> {
match (self, event) {
(ButtonState::Idle, POINTER_ENTER) => Some(ButtonState::Hovered),
(ButtonState::Hovered, POINTER_LEAVE) => Some(ButtonState::Idle),
(ButtonState::Hovered, POINTER_DOWN) => Some(ButtonState::Pressed),
(ButtonState::Pressed, POINTER_UP) => Some(ButtonState::Hovered),
_ => None,
}
}
}
}
Available Event Types
#![allow(unused)]
fn main() {
// Pointer events
POINTER_ENTER // Mouse enters element
POINTER_LEAVE // Mouse leaves element
POINTER_DOWN // Mouse button pressed
POINTER_UP // Mouse button released
POINTER_MOVE // Mouse moved over element
// Keyboard events
KEY_DOWN // Key pressed
KEY_UP // Key released
TEXT_INPUT // Character typed
// Focus events
FOCUS // Element gained focus
BLUR // Element lost focus
// Other
SCROLL // Scroll event
DRAG // Drag motion
DRAG_END // Drag completed
}
Stateful Elements
Creating Stateful Elements
#![allow(unused)]
fn main() {
use blinc_layout::prelude::*;
fn interactive_card() -> impl ElementBuilder {
stateful::<ButtonState>()
.w(200.0)
.h(120.0)
.rounded(12.0)
.on_state(|ctx| {
let bg = match ctx.state() {
ButtonState::Idle => Color::rgba(0.15, 0.15, 0.2, 1.0),
ButtonState::Hovered => Color::rgba(0.18, 0.18, 0.25, 1.0),
ButtonState::Pressed => Color::rgba(0.12, 0.12, 0.16, 1.0),
ButtonState::Disabled => Color::rgba(0.1, 0.1, 0.12, 0.5),
};
div().bg(bg).child(text("Hover me").color(Color::WHITE))
})
}
}
How It Works
- Builder creation:
stateful::<S>()creates a StatefulBuilder for state type S - Key generation: Automatic key based on call site location
- Event routing: Pointer/keyboard events are routed to the FSM
- State transition: FSM computes new state from (current_state, event)
- Callback invocation:
on_statecallback runs with StateContext - Visual update: Returned Div is merged onto container
StateContext API
The callback receives a StateContext with these methods:
#![allow(unused)]
fn main() {
.on_state(|ctx| {
// Get current state
let state = ctx.state();
// Get triggering event (if any)
if let Some(event) = ctx.event() {
// Handle specific event types
match event.event_type {
POINTER_UP => println!("Clicked!"),
_ => {}
}
}
// Create scoped signals
let counter = ctx.use_signal("counter", || 0);
// Create animated values (spring physics)
let scale = ctx.use_spring("scale", 1.0, SpringConfig::snappy());
// Create animated timelines (keyframe sequences)
let (entry_id, timeline) = ctx.use_timeline("fade", |t| {
let id = t.add(0, 500, 0.0, 1.0);
t.set_loop(-1);
t.start();
id
});
// Create keyframe animations with fluent API
let anim = ctx.use_keyframes("pulse", |k| {
k.at(0, 0.8).at(800, 1.2).ease(Easing::EaseInOut).ping_pong().loop_infinite()
});
// Access dependency values by index
let value: i32 = ctx.dep(0).unwrap_or_default();
// Get dependency as State handle
let state_handle = ctx.dep_as_state::<i32>(0);
// Dispatch events
ctx.dispatch(CUSTOM_EVENT);
div()
})
}
Built-in State Types
ButtonState
#![allow(unused)]
fn main() {
enum ButtonState {
Idle, // Default
Hovered, // Mouse over
Pressed, // Mouse down
Disabled, // Non-interactive
}
}
Transitions:
- Idle → Hovered (pointer enter)
- Hovered → Idle (pointer leave)
- Hovered → Pressed (pointer down)
- Pressed → Hovered (pointer up)
NoState
For elements that only need dependency tracking:
#![allow(unused)]
fn main() {
stateful::<NoState>()
.deps([signal.signal_id()])
.on_state(|_ctx| {
div().child(text("Rebuilds on signal change"))
})
}
ToggleState
#![allow(unused)]
fn main() {
enum ToggleState {
Off,
On,
}
}
Transitions:
- Off → On (click)
- On → Off (click)
CheckboxState
#![allow(unused)]
fn main() {
enum CheckboxState {
UncheckedIdle,
UncheckedHovered,
CheckedIdle,
CheckedHovered,
}
}
TextFieldState
#![allow(unused)]
fn main() {
enum TextFieldState {
Idle,
Hovered,
Focused,
FocusedHovered,
Disabled,
}
}
ScrollState
#![allow(unused)]
fn main() {
enum ScrollState {
Idle,
Scrolling,
Decelerating,
Bouncing,
}
}
Signal Dependencies
Stateful elements can depend on external signals using .deps():
#![allow(unused)]
fn main() {
fn counter_display(count: State<i32>) -> impl ElementBuilder {
stateful::<ButtonState>()
.deps([count.signal_id()]) // Re-run on_state when count changes
.on_state(move |ctx| {
// Access via captured variable
let value = count.get();
// Or via context by index
let value_alt: i32 = ctx.dep(0).unwrap_or_default();
div().child(
text(&format!("Count: {}", value)).color(Color::WHITE)
)
})
}
}
Accessing Dependencies
Two patterns for accessing dependency values:
#![allow(unused)]
fn main() {
// Pattern 1: Capture in closure
let my_signal = use_state(|| 42);
stateful::<ButtonState>()
.deps([my_signal.signal_id()])
.on_state(move |ctx| {
let value = my_signal.get(); // Via captured variable
div()
})
// Pattern 2: Access via context
stateful::<ButtonState>()
.deps([my_signal.signal_id()])
.on_state(|ctx| {
let value: i32 = ctx.dep(0).unwrap_or_default(); // Via index
div()
})
}
When to Use .deps()
Without .deps() | With .deps() |
|---|---|
| Only runs on state transitions | Also runs when dependencies change |
| Hover/press only | External data + hover/press |
Scoped State Management
StateContext provides scoped utilities that persist across rebuilds:
Scoped Signals
#![allow(unused)]
fn main() {
stateful::<ButtonState>()
.on_state(|ctx| {
// Signal keyed as "{stateful_key}:signal:click_count"
let clicks = ctx.use_signal("click_count", || 0);
div()
.child(text(&format!("Clicks: {}", clicks.get())))
.on_click(move |_| clicks.update(|n| n + 1))
})
}
Springs (use_spring)
#![allow(unused)]
fn main() {
stateful::<ButtonState>()
.on_state(|ctx| {
// Target value changes based on state
let target = match ctx.state() {
ButtonState::Hovered => 1.1,
_ => 1.0,
};
// use_spring automatically animates to target
let scale = ctx.use_spring("scale", target, SpringConfig::snappy());
div().transform(Transform::scale(scale, scale))
})
}
Keyframes (use_keyframes)
#![allow(unused)]
fn main() {
stateful::<ButtonState>()
.on_state(|ctx| {
// Keyframe animation with ping-pong and easing
let pulse = ctx.use_keyframes("pulse", |k| {
k.at(0, 0.8)
.at(800, 1.2)
.ease(Easing::EaseInOut)
.ping_pong()
.loop_infinite()
.start()
});
let scale = pulse.get();
div().transform(Transform::scale(scale, scale))
})
}
Timelines (use_timeline)
#![allow(unused)]
fn main() {
stateful::<NoState>()
.on_state(|ctx| {
// Timeline with staggered entries
let ((bar1, bar2), timeline) = ctx.use_timeline("bars", |t| {
let b1 = t.add_with_easing(0, 500, 0.0, 60.0, Easing::EaseInOut);
let b2 = t.add_with_easing(100, 500, 0.0, 60.0, Easing::EaseInOut);
t.set_alternate(true);
t.set_loop(-1);
t.start();
(b1, b2)
});
let x1 = timeline.get(bar1).unwrap_or(0.0);
let x2 = timeline.get(bar2).unwrap_or(0.0);
div()
.child(div().transform(Transform::translate(x1, 0.0)))
.child(div().transform(Transform::translate(x2, 0.0)))
})
}
Custom State Machines
For complex interactions, define your own states:
#![allow(unused)]
fn main() {
#[derive(Clone, Copy, PartialEq, Eq, Hash, Default)]
enum DragState {
#[default]
Idle,
Hovering,
Pressing,
Dragging,
}
impl StateTransitions for DragState {
fn on_event(&self, event: u32) -> Option<Self> {
match (self, event) {
(DragState::Idle, POINTER_ENTER) => Some(DragState::Hovering),
(DragState::Hovering, POINTER_LEAVE) => Some(DragState::Idle),
(DragState::Hovering, POINTER_DOWN) => Some(DragState::Pressing),
(DragState::Pressing, DRAG) => Some(DragState::Dragging),
(DragState::Pressing, POINTER_UP) => Some(DragState::Hovering),
(DragState::Dragging, DRAG_END) => Some(DragState::Idle),
_ => None,
}
}
}
fn draggable_element() -> impl ElementBuilder {
stateful::<DragState>()
.on_state(|ctx| {
let bg = match ctx.state() {
DragState::Idle => Color::BLUE,
DragState::Hovering => Color::CYAN,
DragState::Pressing => Color::YELLOW,
DragState::Dragging => Color::GREEN,
};
div().w(100.0).h(100.0).bg(bg)
})
}
}
Event Routing
Event Flow
Platform Event (pointer, keyboard)
│
├── Hit test: which element?
│
├── EventRouter dispatches to element
│
├── StateMachine receives event
│ └── Computes transition
│
└── on_state callback invoked
Event Context
Handlers receive event details:
#![allow(unused)]
fn main() {
.on_click(|ctx| {
println!("Clicked at ({}, {})", ctx.local_x, ctx.local_y);
})
.on_key_down(|ctx| {
if ctx.ctrl && ctx.key_code == 83 { // Ctrl+S
save();
}
})
}
Performance
Why FSM Over Signals?
| Signals for visual state | FSM for visual state |
|---|---|
| Triggers full rebuild | Updates only affected element |
| Creates new VDOM | Mutates existing element |
| O(tree size) | O(1) |
Minimal Updates
Stateful elements only update their own RenderProps:
#![allow(unused)]
fn main() {
// State change only affects this element
.on_state(|ctx| {
div().bg(new_color) // Updates RenderProps
// No layout recomputation
// No tree diff
// Just visual update
})
}
Queued Updates
State changes queue updates efficiently:
#![allow(unused)]
fn main() {
static PENDING_PROP_UPDATES: Vec<(NodeId, RenderProps)>;
// Stateful callback queues update
fn on_state(ctx) -> Div {
div().bg(color)
// Queues: (node_id, updated_props)
}
// Processed in batch by windowed app
for (node_id, props) in drain_pending() {
render_tree.update_props(node_id, props);
}
}