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, Android, iOS and Web (WASM + WebGPU).
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))
)
})
}
For AI Agents
If you’re an AI coding agent working with Blinc, see Skills.md in the repository root — a concise, example-driven reference with verified APIs, CSS-first styling patterns, and common pitfalls.
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::<S>() 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);
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(count.clone()))
// Buttons row
.child(
div()
.flex_row()
.gap(16.0)
.child(counter_button(count.clone(), "-", -1))
.child(counter_button(count.clone(), "+", 1))
)
}
}
Step 4: Creating the Count Display
The count display needs to update when the count changes. We use stateful::<NoState>() with .deps():
#![allow(unused)]
fn main() {
fn count_display(count: State<i32>) -> impl ElementBuilder {
stateful::<NoState>()
.deps([count.signal_id()])
.on_state(move |_ctx| {
let current = count.get();
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::<ButtonState>():
#![allow(unused)]
fn main() {
fn counter_button(
count: State<i32>,
label: &'static str,
delta: i32,
) -> impl ElementBuilder {
stateful::<ButtonState>()
.w(60.0)
.h(60.0)
.rounded(12.0)
.flex_center()
.on_state(|ctx| {
// Apply different styles based on current state
let bg = match ctx.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().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(count.clone()))
.child(
div()
.flex_row()
.gap(16.0)
.child(counter_button(count.clone(), "-", -1))
.child(counter_button(count.clone(), "+", 1))
)
}
fn count_display(count: State<i32>) -> impl ElementBuilder {
stateful::<NoState>()
.deps([count.signal_id()])
.on_state(move |_ctx| {
let current = count.get();
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(
count: State<i32>,
label: &'static str,
delta: i32,
) -> impl ElementBuilder {
stateful::<ButtonState>()
.w(60.0)
.h(60.0)
.rounded(12.0)
.flex_center()
.on_state(|ctx| {
let bg = match ctx.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().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
examples/blinc_app_examples/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::<S>() - 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. The same Rust UI code runs on mobile with platform-specific rendering backends (Vulkan for Android, Metal for iOS) and a unified API for native platform features.
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
- Native Bridge: Typed function-call protocol between Rust and Kotlin/Swift
- 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 (matches mobile/example/ in this repo):
my-app/
├── Cargo.toml # Rust workspace + cdylib/staticlib config
├── blinc.toml # Blinc project config
├── .cargo/ # Per-target cargo config (linker, flags)
├── .env # SDK / NDK / signing paths (gitignored)
├── .env.example # Template for .env
├── src/
│ └── main.rs # Shared Rust UI code
├── platforms/
│ ├── android/ # Android Gradle project
│ │ ├── app/
│ │ │ ├── build.gradle.kts
│ │ │ └── src/main/
│ │ │ ├── AndroidManifest.xml
│ │ │ └── kotlin/com/blinc/
│ │ │ ├── MainActivity.kt
│ │ │ └── BlincNativeBridge.kt
│ │ ├── build.gradle.kts
│ │ └── settings.gradle.kts
│ ├── ios/ # iOS Xcode project
│ │ ├── BlincApp/
│ │ │ ├── AppDelegate.swift
│ │ │ ├── BlincViewController.swift
│ │ │ ├── BlincMetalView.swift
│ │ │ ├── BlincNativeBridge.swift
│ │ │ ├── Blinc-Bridging-Header.h
│ │ │ ├── Info.plist
│ │ │ └── Fonts/
│ │ └── BlincApp.xcodeproj/
│ └── harmony/ # HarmonyOS (in progress)
├── build-android.sh # Cross-compile + copy .so → jniLibs
├── build-ios.sh # Cross-compile + copy .a → libs/{device,simulator}
└── build-ohos.sh # HarmonyOS build script
Quick Start
blinc new my-app --template rust
cd my-app
blinc run android # or: blinc run ios
#![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(text(format!("Count: {}", count.get())).size(48.0).color(Color::WHITE))
.child(
button(state.clone(), "+")
.on_click(move |_| count.set(count.get() + 1))
)
}
}
Native Bridge
Blinc’s native bridge provides a typed function-call protocol between Rust and Kotlin/Swift. Use it for any platform feature not in the framework core: camera, biometrics, push notifications, native dialogs, etc.
Setup required. The bridge does NOT work out of the box — you must wire it up at app startup on each platform. The example project (
mobile/example/) shows the canonical wiring; copy the relevant bits into your ownMainActivity.ktandAppDelegate.swift. Without this, everynative_callwill fail with “handler not found”.
Rust side — call into native
#![allow(unused)]
fn main() {
use blinc_core::native_bridge::native_call;
// Synchronous call returning a value
let level: String = native_call("device", "get_battery_level", ())?;
// Pass arguments
native_call::<(), _>("notify", "show", ("Hello", "World"))?;
// Built-in haptic helpers
native_call::<(), _>("haptics", "selection", ())?;
native_call::<(), _>("haptics", "impact", (1i32,))?; // 0=light, 1=medium, 2=heavy
native_call::<(), _>("haptics", "success", ())?;
}
Kotlin side — register handlers
Copy BlincNativeBridge.kt from mobile/example/platforms/android/app/src/main/kotlin/com/blinc/ into your project — it’s the JNI shim that Rust calls into.
// MainActivity.kt — companion object init block:
companion object {
init {
System.loadLibrary("my_app")
}
}
// In onCreate:
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// REQUIRED: register the built-in handlers (haptics, device info,
// keyboard show/hide, clipboard) before the Rust frame loop starts.
BlincNativeBridge.registerDefaults(this)
// Optional: register your own custom handlers
BlincNativeBridge.registerString("device", "get_battery_level") {
val bm = getSystemService(Context.BATTERY_SERVICE) as BatteryManager
bm.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY).toString()
}
BlincNativeBridge.registerVoid("notify", "show") { args ->
val title = args.getString(0)
val body = args.getString(1)
NotificationHelper.show(this, title, body)
}
}
Swift side — register handlers
Copy BlincNativeBridge.swift from mobile/example/platforms/ios/BlincApp/ into your project — it’s the C-FFI shim that Rust calls into.
// AppDelegate.swift — application(_:didFinishLaunchingWithOptions:)
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
// REQUIRED: register defaults BEFORE connectToRust so the
// function pointer table is populated when Rust starts calling.
BlincNativeBridge.shared.registerDefaults()
BlincNativeBridge.shared.connectToRust()
// Optional: register your own custom handlers
BlincNativeBridge.shared.registerString(
namespace: "device",
name: "get_battery_level"
) { _ in
UIDevice.current.isBatteryMonitoringEnabled = true
return String(Int(UIDevice.current.batteryLevel * 100))
}
BlincNativeBridge.shared.registerVoid(
namespace: "notify",
name: "show"
) { args in
let title = args[0] as? String ?? ""
let body = args[1] as? String ?? ""
NotificationHelper.show(title: title, body: body)
}
return true
}
Order matters:
registerDefaults()must be called BEFOREconnectToRust()so the Swift-side handler table is populated when Rust starts dispatching calls.
Streams (camera, audio, sensors)
Streams deliver continuous data (frames, samples, sensor readings) from the platform back to Rust without polling. The platform pushes data via dispatch_stream_data, which fires the registered Rust callback. Drop the returned NativeStream handle to stop the stream and release resources.
#![allow(unused)]
fn main() {
use blinc_core::native_bridge::{native_stream, NativeValue};
let stream = native_stream(
"sensors",
"accelerometer",
NativeValue::Null,
|data| {
if let Some(arr) = data.as_array() {
let x = arr[0].as_f32().unwrap_or(0.0);
let y = arr[1].as_f32().unwrap_or(0.0);
let z = arr[2].as_f32().unwrap_or(0.0);
println!("accel: {x}, {y}, {z}");
}
},
)?;
// drop(stream) → stream stops
}
The platform side calls nativeDispatchStreamData(streamId, byteArray) (Android JNI) or blinc_dispatch_stream_data(stream_id, ptr, len) (iOS C FFI) to push data into the Rust callback.
Camera capture
CameraStream from blinc_media wraps the bridge stream API in a typed reactive interface:
#![allow(unused)]
fn main() {
use blinc_media::{CameraStream, CameraConfig, CameraFacing};
let camera = CameraStream::open(CameraConfig {
width: 640,
height: 480,
fps: 30,
facing: CameraFacing::Front,
});
// Read latest frame in build_ui
if let Some(frame) = camera.latest_frame() {
canvas(move |ctx, bounds| {
ctx.draw_rgba_pixels(frame.as_rgba(), frame.width, frame.height, bounds);
})
}
// drop(camera) stops capture and releases the device
}
The platform side uses Camera2 (Android) or AVCaptureSession (iOS) and pushes frames through the native bridge stream protocol.
Note: A complete camera demo example is on the roadmap. The API surface above is stable.
Audio recording
#![allow(unused)]
fn main() {
use blinc_media::{AudioRecorder, AudioRecorderConfig};
let recorder = AudioRecorder::open(AudioRecorderConfig {
sample_rate: 44100,
channels: 1,
});
if let Some(samples) = recorder.latest_samples() {
process_audio(samples.as_f32());
}
}
Platform side: AudioRecord (Android) or AVAudioRecorder (iOS) streams 16-bit PCM through the bridge.
Deep Linking
Blinc Router auto-handles deep links — no manual wiring required after RouterBuilder::build().
Rust — define routes
#![allow(unused)]
fn main() {
use blinc_router::RouterBuilder;
let router = RouterBuilder::new()
.route("/", home_page)
.route("/users/:id", user_detail)
.route("/products/:slug", product_page)
.build();
// router is auto-wired to dispatch_deep_link
// myapp://users/42 → router.push("/users/42") → user_detail({id: "42"})
}
Android — forward intents to Rust
// MainActivity.kt
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
intent.data?.toString()?.let { uri ->
nativeDispatchDeepLink(uri)
}
}
external fun nativeDispatchDeepLink(uri: String)
iOS — forward URLs to Rust
// AppDelegate.swift
func application(
_ app: UIApplication,
open url: URL,
options: [UIApplication.OpenURLOptionsKey : Any] = [:]
) -> Bool {
blinc_ios_handle_deep_link(url.absoluteString)
return true
}
// SceneDelegate.swift (for scene-based apps)
func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) {
URLContexts.forEach { ctx in
blinc_ios_handle_deep_link(ctx.url.absoluteString)
}
}
The system back button is also auto-registered: Key::Back events route through router.back().
App Lifecycle
#![allow(unused)]
fn main() {
use blinc_platform::event::{Event, LifecycleEvent};
match event {
Event::Lifecycle(LifecycleEvent::Resumed) => {
camera.resume();
analytics.session_start();
}
Event::Lifecycle(LifecycleEvent::Suspended) => {
camera.pause();
save_state();
}
Event::Lifecycle(LifecycleEvent::LowMemory) => {
clear_image_cache();
}
_ => {}
}
}
| Blinc Event | Android | iOS |
|---|---|---|
Resumed | MainEvent::Resume | applicationDidBecomeActive |
Suspended | MainEvent::Pause | applicationWillResignActive |
LowMemory | MainEvent::LowMemory | applicationDidReceiveMemoryWarning |
Soft Keyboard
Text input widgets (text_input(), text_area()) automatically show/hide the soft keyboard on focus. The keyboard inset is reported back via WindowedContext.safe_bottom() so your layout can adjust.
#![allow(unused)]
fn main() {
text_input(state)
.placeholder("Type something...")
}
Implementation:
- Android: keyboard show/hide commands dispatched via the native bridge under
keyboard.show/keyboard.hide. Default handlers (registered byBlincNativeBridge.registerDefaults) callInputMethodManager.showSoftInput/hideSoftInputFromWindow. - iOS:
blinc_ios_show_keyboard()/blinc_ios_hide_keyboard()C FFI invoked from the frame loop. Inset reported back viablinc_ios_set_keyboard_inset(ctx, inset)from akeyboardWillShowobserver.
Edit Menu (iOS 16+)
Text input widgets automatically integrate with UIEditMenuInteraction on iOS 16+. Long-press a text field to see the system Cut/Copy/Paste/Select menu — no manual wiring required. The native bridge handles UIPasteboard clipboard read/write, menu presentation, and word selection.
Safe Area Insets
WindowedContext exposes the OS-reported safe-area insets — notch, status bar, nav bar, home indicator, gesture bar, landscape camera cutouts — in logical pixels, matching ctx.width / ctx.height:
#![allow(unused)]
fn main() {
pub fn build_ui(ctx: &mut WindowedContext) -> impl ElementBuilder {
div()
.w(ctx.width).h(ctx.height)
.pt(ctx.safe_top()) // status bar / notch
.pb(ctx.safe_bottom()) // home indicator / gesture bar
.pl(ctx.safe_left()) // landscape notch
.pr(ctx.safe_right())
.child(/* ... */)
}
}
- iOS: read from
UIWindow.safeAreaInsetsviaobjc2at context-creation time. Fetched from the first key window of the first foreground-activeUIWindowScene. - Android: delivered by
BlincNativeBridge’ssetOnApplyWindowInsetsListeneron the decor view. On API 30+ it mergesWindowInsets.Type.systemBars()withWindowInsets.Type.displayCutout()so landscape notches are covered; on API 24–29 it falls back to the (deprecated but functional)systemWindowInset*accessors. The four values are pushed into Rust via thenativeDispatchSafeAreaJNI export; theandroid_mainpoll loop copies them intoWindowedContext.safe_areawhenever an edge changes (rotation, split-screen, PiP exit, immersive-mode toggle). - Desktop / Web / Fuchsia: always
(0, 0, 0, 0).
safe_width() / safe_height() return the content rect with both horizontal or both vertical insets subtracted, for when you want the full safe content area as a single number.
Touch Event Handling
Touch events are automatically routed to your UI:
| Android Action | iOS Phase | Blinc Event |
|---|---|---|
ACTION_DOWN | touchesBegan | pointer_down |
ACTION_MOVE | touchesMoved | pointer_move |
ACTION_UP | touchesEnded | pointer_up + pointer_leave |
ACTION_CANCEL | touchesCancelled | pointer_leave |
Two-finger pinch gestures emit PINCH events with center + scale. Use .on_pinch() and .on_rotate() on a Div to receive them.
Next Steps
- Android Development — Toolchain setup, build commands, manifest configuration, debugging
- iOS Development — Toolchain setup, build commands, Xcode configuration, debugging
- CLI Reference — Full CLI command reference
Android Project Setup
This guide covers setting up an Android Blinc project — toolchain, build commands, and the platform-specific files (AndroidManifest.xml, Gradle config, debugging).
For the cross-platform Blinc API (native bridge, camera, deep linking, lifecycle, etc.), see the Mobile Development overview.
Prerequisites
1. Android SDK & NDK
# macOS
brew install --cask android-studio
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 Targets
rustup target add aarch64-linux-android
rustup target add armv7-linux-androideabi
rustup target add x86_64-linux-android
cargo install cargo-ndk
Building
# Debug — single arch
cargo ndk -t arm64-v8a build
# Release — multi-arch
cargo ndk -t arm64-v8a -t armeabi-v7a build --release
# Or via Gradle (from platforms/android/)
./gradlew assembleDebug
The APK lands in 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.5", features = ["android"] }
blinc_platform_android = "0.5"
android-activity = { version = "0.6", features = ["native-activity"] }
log = "0.4"
android_logger = "0.14"
AndroidManifest.xml
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-feature android:glEsVersion="0x00030000" android:required="true" />
<!-- Permissions for native bridge features -->
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.INTERNET" />
<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"
android:launchMode="singleTask">
<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>
<!-- Deep link: myapp://path/to/route -->
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="myapp" />
</intent-filter>
</activity>
</application>
</manifest>
Debugging
# View Rust logs
adb logcat | grep -E "(blinc|BlincApp)"
# Filter for native bridge calls
adb logcat | grep BlincNativeBridge
Common Issues
“Library not found” — ensure the native library is built and copied to app/src/main/jniLibs/<arch>/:
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 capability:
adb shell getprop ro.hardware.vulkan
API 24+ devices generally support Vulkan, but some emulators may not.
“Native call failed” — verify the namespace+name matches between Kotlin and Rust handlers. Check logcat for BlincNativeBridge: handler not found for X.Y.
Touch events not working — verify the render context is created successfully and android.app.lib_name in the manifest matches your library name.
Performance
[profile.release]
lto = "fat"
opt-level = "z" # optimize for size on mobile
panic = "abort"
strip = true
codegen-units = 1
- Test on real devices — emulators have different GPU characteristics
- Profile with Android Studio Profiler for CPU/GPU/memory
- Bundle assets via
assets/—AndroidAssetLoaderauto-resolves them through the platformAssetLoadertrait
Next Steps
- Mobile Development overview — native bridge, camera, deep linking, lifecycle, safe area APIs
- iOS Project Setup — build the iOS counterpart
- CLI Reference
iOS Project Setup
This guide covers setting up an iOS Blinc project — toolchain, build commands, and the platform-specific files (Info.plist, Xcode configuration, debugging).
For the cross-platform Blinc API (native bridge, camera, deep linking, lifecycle, etc.), see the Mobile Development overview.
Prerequisites
1. Xcode
Install Xcode 15+ from the App Store.
xcode-select -p
2. Rust Targets
rustup target add aarch64-apple-ios # Device
rustup target add aarch64-apple-ios-sim # Simulator (Apple Silicon)
rustup target add x86_64-apple-ios # Simulator (Intel)
Building
Create a build script build-ios.sh:
#!/bin/bash
set -e
MODE=${1:-debug}
PROJECT_NAME="my_app"
[ "$MODE" = "release" ] && CARGO_FLAGS="--release" || CARGO_FLAGS=""
TARGET_DIR=$([ "$MODE" = "release" ] && echo "release" || echo "debug")
cargo build --target aarch64-apple-ios $CARGO_FLAGS
cargo build --target aarch64-apple-ios-sim $CARGO_FLAGS
mkdir -p platforms/ios/libs/{device,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/
./build-ios.sh # debug
./build-ios.sh release
Then open platforms/ios/BlincApp.xcodeproj in Xcode and press Cmd+R.
Project Configuration
Cargo.toml
[lib]
name = "my_app"
crate-type = ["cdylib", "staticlib"]
[target.'cfg(target_os = "ios")'.dependencies]
blinc_app = { version = "0.5", features = ["ios"] }
blinc_platform_ios = "0.5"
Xcode Build Settings
- Link static library: Build Phases → Link Binary With Libraries → add
libmy_app.afromlibs/device/orlibs/simulator/ - Bridging header: Build Settings → Objective-C Bridging Header →
BlincApp/Blinc-Bridging-Header.h - Frameworks:
Metal.frameworkMetalKit.frameworkQuartzCore.frameworkAVFoundation.framework(camera/audio)CoreHaptics.framework(haptics)
Info.plist
<!-- Camera + microphone permissions for native bridge features -->
<key>NSCameraUsageDescription</key>
<string>This app uses the camera for photo capture.</string>
<key>NSMicrophoneUsageDescription</key>
<string>This app records audio.</string>
<!-- Deep link URL scheme -->
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLSchemes</key>
<array><string>myapp</string></array>
</dict>
</array>
Bridging Header
The bridging header (Blinc-Bridging-Header.h) declares the C FFI surface Swift uses to call Rust:
// 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);
// Deep linking + keyboard (see Mobile overview for usage)
void blinc_ios_handle_deep_link(const char* uri);
void blinc_ios_set_keyboard_inset(IOSRenderContext* ctx, float inset);
The BlincViewController template manages the CADisplayLink, CAMetalLayer, and touch event forwarding to Rust.
Debugging
Console Logs
View Rust logs in Xcode’s console or Console.app with a filter:
subsystem:com.blinc.my_app
Common Issues
“Library not found: -lmy_app” — run ./build-ios.sh first.
Black screen on simulator —
- Verify the right simulator target (
aarch64-apple-ios-simfor Apple Silicon,x86_64-apple-iosfor Intel) - Verify the static library is in
libs/simulator/ - Check Xcode console for Metal initialization errors
Touch events not working —
- Verify
blinc_create_contextsucceeds (check console) - Ensure
ios_app_init()is called before creating the context - Touch coordinates must be in logical points, not physical pixels
Native call failed — verify Swift handler is registered with matching namespace.name. Check that BlincNativeBridge.shared.connectToRust() was called at app launch.
Performance
[profile.release]
lto = "fat"
opt-level = "z"
panic = "abort"
strip = true
codegen-units = 1
- Test on real devices — simulators use software rendering for some Metal operations
- Profile with Instruments — use the Metal System Trace template for GPU analysis
Next Steps
- Mobile Development overview — native bridge, camera, deep linking, lifecycle, safe area APIs
- Android Project Setup — build the Android counterpart
- CLI Reference
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
Example Gallery
Every example in examples/blinc_app_examples/examples/
that follows the cross-target convention is auto-built for the web
target by tools/build-web-examples and embedded below. The same
build_ui function that runs on desktop, iOS, and Android runs
here — no per-target forks. See the
Contributing → Examples page for the
convention that makes this work.
Click any card to open the example in a focused view with a lazy-loaded iframe. Each demo spawns its own WebGPU context, so loading more than ~8 at once will start hitting Chrome’s per-tab GPU context limit — the per-example pages keep that manageable.
Examples
- Canvas Element — This example demonstrates the canvas element for custom GPU drawing
- Canvas Kit Interactive — Demonstrates
blinc_canvas_kitfeatures: - Carousel Demo - Selector API Showcase — Demonstrates the new selector API features:
- Chrome-Style Tabs — Demonstrates the notch element in “reverse” mode: instead of a dropdown
- blinc_cn Components — Showcases all available blinc_cn components in a scrollable grid layout.
- Code Element — Demonstrates both read-only code display and editable code editor.
- Complex SVG — Displays an SVG at various sizes to test rasterization quality,
- CSS Debug — Tests three known CSS issues:
- CSS Visual Features — Showcases newly added CSS visual features:
- Layer Effects — Showcases GPU-accelerated layer effects including:
- Emoji and HTML Entities — This example demonstrates:
- @flow Shader — Demonstrates the @flow DAG-based shader system.
- Fluid Surface — Combines
@flowGPU shaders withpointer-queryCSS-driven interaction. - Skeleton animation with glTF +
blinc_canvas_kit. — Loads Sketchfab’s buster_drone (39 meshes, 92 nodes, one 25-second - Image CSS Styling — Demonstrates CSS properties that work on images via stylesheets:
- Image Layer Test — Tests the rendering order of images vs primitives (paths, backgrounds).
- Keyframe Animation Canvas — Demonstrates keyframe animations with the canvas element for:
- Markdown Editor — A split-view markdown editor with:
- 3D Mesh Demo — renders the Khronos glTF
DamagedHelmetsample model — Demonstrates: - Motion Demo — Auto-built from the cross-target source.
- Music Player Glass Card — Recreates an iOS-style “Now Playing” music player card with liquid glass
- Notch Menu Bar — Demonstrates a macOS-style menu bar with a notched dropdown that slides
- Overflow Fade — Demonstrates the
overflow-fadeCSS property which applies smooth alpha - Overlay System — This example demonstrates the overlay infrastructure for modals, dialogs,
- Pointer Query — Demonstrates the CSS-driven continuous pointer query system.
- Rich Text Element — This example demonstrates the rich_text element for inline text formatting
- Rich Text Editor — Full editable rich text editor with cursor, selection, and inline
- Scroll Container — This example demonstrates the scroll widget with webkit-style
- Semantic @flow — Demonstrates the semantic step/chain/use system for @flow shaders.
- Sortable — Demonstrates drag-based interactions using FSM-driven stateful containers:
- Stateful API — This example demonstrates the new stateful::
() API with: - End-to-end 3D demo wiring Blinc’s SceneKit3D renderer up to — dispatch front-end used by any Blinc app that wants to drop
- Unified Styling API — Demonstrates all styling approaches in Blinc:
- SVG Animation — Demonstrates SVG animation capabilities:
- Table Builder — This example demonstrates the TableBuilder API for declarative table creation.
- Tabler Icons — Showcases outline and filled icons from the blinc_tabler_icons crate.
- Minimal text positioning test — Tests that text is correctly centered within parent containers.
- Text Input Widgets — Demonstrates ready-to-use text input and text area elements using the layout API.
- KHR_texture_transform — Loads Poly Haven’s
marble_cliff_02asset (CC0) — a displaced - Theme System — This example demonstrates the Blinc theming system capabilities:
- Timeline Animation — This example demonstrates timeline-based animations using the stateful API:
- Typography — This example demonstrates typography helpers:
- Video Player — Demonstrates the video_player widget with
blinc_media::VideoPlayerinstance and controls. - Wet Glass — Procedural wet-window effect with real light refraction through water drops.
- Windowed Application — This example demonstrates how to create a windowed Blinc application
Canvas Element
This example demonstrates the canvas element for custom GPU drawing within the layout system.
Features demonstrated:
- Custom 2D drawing with DrawContext
- Canvas respects layout transforms and clipping
- Procedural graphics (animated shapes, patterns)
- Canvas for cursor/indicator rendering
- BlincComponent derive macro for type-safe animation hooks
Tip: Some demos are best viewed in a full browser window. Click “Open in a new tab” below for the full experience.
Open in a new tab · View source on GitHub
Canvas Kit Interactive
Demonstrates blinc_canvas_kit features:
- Pan (drag background) and zoom (scroll wheel) on an infinite canvas
kit.element()builder with auto-wired event handlerskit.handler()for custom event wiring- Hit testing via
kit.hit_rect()inside draw callbacks - Click, drag, and hover callbacks on canvas-drawn elements
- Viewport state HUD overlay
Tip: Some demos are best viewed in a full browser window. Click “Open in a new tab” below for the full experience.
Open in a new tab · View source on GitHub
Carousel Demo - Selector API Showcase
Demonstrates the new selector API features:
- ScrollRef for programmatic scroll control
- Element IDs for targeting elements
- scroll_to() to scroll to elements by ID
- ScrollOptions with ScrollBlock::Center for centering cards
Tip: Some demos are best viewed in a full browser window. Click “Open in a new tab” below for the full experience.
Open in a new tab · View source on GitHub
Chrome-Style Tabs
Demonstrates the notch element in “reverse” mode: instead of a dropdown hanging BELOW a bar with concave top corners, Chrome-style tabs sit ABOVE a toolbar with concave BOTTOM corners. The concave curves flare outward past the tab’s box and visually merge with the toolbar beneath, giving the active tab its signature smooth connection to the toolbar edge.
Tip: Some demos are best viewed in a full browser window. Click “Open in a new tab” below for the full experience.
Open in a new tab · View source on GitHub
blinc_cn Components
Showcases all available blinc_cn components in a scrollable grid layout.
Tip: Some demos are best viewed in a full browser window. Click “Open in a new tab” below for the full experience.
Open in a new tab · View source on GitHub
Code Element
Demonstrates both read-only code display and editable code editor.
Features demonstrated:
- Syntax highlighting with built-in Rust and JSON highlighters
- Line numbers in the gutter
- Editable code editor with Stateful incremental updates
- Cursor, selection, clipboard (Cmd+C/X/V), undo/redo (Cmd+Z)
- Word navigation (Cmd+Left/Right), select all (Cmd+A)
- Vertical scrolling in the editor
Tip: Some demos are best viewed in a full browser window. Click “Open in a new tab” below for the full experience.
Open in a new tab · View source on GitHub
Complex SVG
Displays an SVG at various sizes to test rasterization quality, anti-aliasing, and HiDPI scaling.
Tip: Some demos are best viewed in a full browser window. Click “Open in a new tab” below for the full experience.
Open in a new tab · View source on GitHub
CSS Debug
Tests three known CSS issues:
- var() not picking up values from :root
- width/height percentage not working
- Text not inheriting color from parent div
Tip: Some demos are best viewed in a full browser window. Click “Open in a new tab” below for the full experience.
Open in a new tab · View source on GitHub
CSS Visual Features
Showcases newly added CSS visual features:
- mix-blend-mode: Blend overlapping elements (multiply, screen, overlay, etc.)
- pointer-events: Control click-through behavior
- cursor: CSS cursor style on hover
- text-decoration: Underline, line-through with color and thickness
- text-overflow: Ellipsis truncation with white-space: nowrap
Tip: Some demos are best viewed in a full browser window. Click “Open in a new tab” below for the full experience.
Open in a new tab · View source on GitHub
Layer Effects
Showcases GPU-accelerated layer effects including:
- Blur (element blur, not backdrop blur)
- Drop shadows
- Glow effects
- Color matrix transforms (grayscale, sepia, saturation, brightness, contrast)
Tip: Some demos are best viewed in a full browser window. Click “Open in a new tab” below for the full experience.
Open in a new tab · View source on GitHub
Emoji and HTML Entities
This example demonstrates:
- HTML entity decoding in text() elements
- Emoji rendering with system fonts
- ASCII special characters
- Unicode symbols
Tip: Some demos are best viewed in a full browser window. Click “Open in a new tab” below for the full experience.
Open in a new tab · View source on GitHub
@flow Shader
Demonstrates the @flow DAG-based shader system.
Custom GPU fragment shaders are defined in CSS via @flow blocks
and applied to elements with flow: <name>.
Tip: Some demos are best viewed in a full browser window. Click “Open in a new tab” below for the full experience.
Open in a new tab · View source on GitHub
Fluid Surface
Combines @flow GPU shaders with pointer-query CSS-driven interaction.
A central card renders a pointer-reactive fluid shader, while surrounding
labels respond to cursor proximity via calc(env(pointer-distance)).
Tip: Some demos are best viewed in a full browser window. Click “Open in a new tab” below for the full experience.
Open in a new tab · View source on GitHub
Skeleton animation with glTF + blinc_canvas_kit.
Loads Sketchfab’s buster_drone (39 meshes, 92 nodes, one 25-second
Start_Liftoff clip), runs the clip through blinc_skeleton each
frame, and renders the result with SceneKit3D. Asset load is
non-blocking: the UI paints a loading overlay while a background
thread parses the glTF, then flips a scene_ready signal that
the overlay’s Stateful subtree dismisses itself on.
The model is “Buster Drone” by LaVADraGoN
(https://sketchfab.com/3d-models/buster-drone-294e79652f494130ad2ab00a13fdbafd),
licensed CC-BY-4.0 (http://creativecommons.org/licenses/by/4.0/).
Full attribution alongside the asset in assets/3d/buster_drone/license.txt.
Controls:
- Drag: orbit
- Scroll: zoom
- Space: pause / resume
- R: reset clip time
- Left / Right: scrub ±1 frame
cargo run -p blinc_app_examples --example gltf_animation_demo \
--features windowed --release
Tip: Some demos are best viewed in a full browser window. Click “Open in a new tab” below for the full experience.
Open in a new tab · View source on GitHub
Image CSS Styling
Demonstrates CSS properties that work on images via stylesheets:
- opacity, border-radius, border, box-shadow
- transform (rotate, scale, translate) via parent divs
- CSS transitions and hover effects on image containers
- CSS filters (grayscale, sepia, invert, brightness, contrast, saturate, hue-rotate)
Images are wrapped in divs with IDs for CSS targeting, since Image elements inherit CSS properties from their parent container’s RenderProps.
Tip: Some demos are best viewed in a full browser window. Click “Open in a new tab” below for the full experience.
Open in a new tab · View source on GitHub
Image Layer Test
Tests the rendering order of images vs primitives (paths, backgrounds). This helps debug z-order issues where images may render above/below other elements.
Solution for rendering elements ON TOP of images:
Use .foreground() on any element that needs to render above images.
The render order is: Background primitives → Images → Foreground primitives
Tip: Some demos are best viewed in a full browser window. Click “Open in a new tab” below for the full experience.
Open in a new tab · View source on GitHub
Keyframe Animation Canvas
Demonstrates keyframe animations with the canvas element for:
- Spinning loader with rotation keyframes
- Pulsing dots animation
- Progress bar with eased fill
- Bouncing ball animation
Tip: Some demos are best viewed in a full browser window. Click “Open in a new tab” below for the full experience.
Open in a new tab · View source on GitHub
Markdown Editor
A split-view markdown editor with:
- TextArea on the left for writing markdown source
- Scroll container on the right for live preview
Tip: Some demos are best viewed in a full browser window. Click “Open in a new tab” below for the full experience.
Open in a new tab · View source on GitHub
3D Mesh Demo — renders the Khronos glTF DamagedHelmet sample model
Demonstrates:
blinc_canvas_kit::SceneKit3D— orbit camera + light rig wrapped around acanvaselement, with drag/scroll input wired for free.DrawContext::draw_mesh_data— the direct-render mesh path. The canvas closure just callsctx.draw_mesh_data(&mesh, transform); everything behind that (camera capture, pending-mesh queue, GpuPaintContext → GpuRenderer dispatch, PBR shading) is plumbing.- Inline glTF loading — no external
gltfcrate dep. The sample model has a fixed layout (single mesh, single primitive, packed f32 attributes at known bufferView offsets, u16 indices), so parsing is a handful of offset reads plus ablinc_image::ImageDatacall for the albedo texture. - Non-blocking asset loading. On desktop the mesh + HDR decode is
cheap and runs synchronously; on wasm the
WebAssetLoaderpreload is background-spawned by the wrapper, sobuild_uireturns before any asset is cached. Aspawn_localpolling loop waits for the preload, then populates a shared slot that the Stateful viewport wrapper swaps the loading overlay out for.
Tip: Some demos are best viewed in a full browser window. Click “Open in a new tab” below for the full experience.
Open in a new tab · View source on GitHub
Motion Demo
This example is auto-generated from the cross-target source in examples/blinc_app_examples/examples/. See the linked source file for the full details.
Tip: Some demos are best viewed in a full browser window. Click “Open in a new tab” below for the full experience.
Open in a new tab · View source on GitHub
Music Player Glass Card
Recreates an iOS-style “Now Playing” music player card with liquid glass
morphism effect (refracted bevel borders). All visual styling is driven
by CSS via ctx.add_css().
Tip: Some demos are best viewed in a full browser window. Click “Open in a new tab” below for the full experience.
Open in a new tab · View source on GitHub
Notch Menu Bar
Demonstrates a macOS-style menu bar with a notched dropdown that slides horizontally between icons. The dropdown maintains a seamless visual connection to the menu bar via concave curves.
Tip: Some demos are best viewed in a full browser window. Click “Open in a new tab” below for the full experience.
Open in a new tab · View source on GitHub
Overflow Fade
Demonstrates the overflow-fade CSS property which applies smooth alpha
fading at overflow clip edges instead of hard clipping.
Supports:
- Uniform fade:
overflow-fade: 24px(all edges) - Vertical/horizontal:
overflow-fade: 24px 0px(top/bottom only) - Per-edge:
overflow-fade: 24px 0px 24px 0px - CSS transitions and @keyframes animation
- Works with scroll containers
Tip: Some demos are best viewed in a full browser window. Click “Open in a new tab” below for the full experience.
Open in a new tab · View source on GitHub
Overlay System
This example demonstrates the overlay infrastructure for modals, dialogs, context menus, and toast notifications.
Features demonstrated:
- Modal dialogs with backdrop
- Toast notifications in corners
- Context menus at cursor position
- Overlay manager accessed via
ctx.overlay_manager()
Tip: Some demos are best viewed in a full browser window. Click “Open in a new tab” below for the full experience.
Open in a new tab · View source on GitHub
Pointer Query
Demonstrates the CSS-driven continuous pointer query system.
All pointer-reactive effects are defined purely in CSS using
calc(env(pointer-*)) expressions — no Rust pointer reads needed.
The pointer query system binds cursor position to ANY numerical CSS property: opacity, corner-radius, border-width, rotate, and more.
CSS properties used: pointer-space: self; — enables pointer tracking pointer-origin: center; — coordinate origin pointer-range: -1.0 1.0; — output range pointer-smoothing: 0.08; — exponential smoothing opacity: calc(env(pointer-)); — hover fade border-radius: calc(env(pointer-)); — dynamic corners border-width: calc(env(pointer-)); — dynamic borders rotate: calc(env(pointer-)); — subtle rotation
Tip: Some demos are best viewed in a full browser window. Click “Open in a new tab” below for the full experience.
Open in a new tab · View source on GitHub
Rich Text Element
This example demonstrates the rich_text element for inline text formatting with HTML-like tags.
Features demonstrated:
- HTML-like inline formatting (, , ,
) - Nested tags
- Inline colors with
- Links with
- Range-based programmatic styling API
- Entity decoding (<, >, &, etc.)
Tip: Some demos are best viewed in a full browser window. Click “Open in a new tab” below for the full experience.
Open in a new tab · View source on GitHub
Rich Text Editor
Full editable rich text editor with cursor, selection, and inline formatting. Demonstrates every block kind and inline mark currently supported by the editor model:
- Headings (H1–H3)
- Paragraphs with mixed bold / italic / underline / strikethrough / inline-code / colored / linked spans
- Bullet and numbered lists, including nested lists via
indent - Block quote
- Horizontal divider
Tip: Some demos are best viewed in a full browser window. Click “Open in a new tab” below for the full experience.
Open in a new tab · View source on GitHub
Scroll Container
This example demonstrates the scroll widget with webkit-style bounce physics, glass clipping, and scroll event handling.
Features demonstrated:
scroll()container with bounce physics- Glass elements clipping properly inside scroll
- Scroll event handling with delta reporting
- Spring animation for edge bounce
- Toggle between vertical and horizontal scroll directions
- Using reactive state system (
ctx.use_state) for state persistence
Tip: Some demos are best viewed in a full browser window. Click “Open in a new tab” below for the full experience.
Open in a new tab · View source on GitHub
Semantic @flow
Demonstrates the semantic step/chain/use system for @flow shaders.
Uses step, chain, and raw node syntax together to create a
layered noise visualization with pointer-reactive color ramping.
The fourth card (“Plasma”) uses the flow! macro to define a flow
shader entirely in Rust — no CSS strings needed.
Tip: Some demos are best viewed in a full browser window. Click “Open in a new tab” below for the full experience.
Open in a new tab · View source on GitHub
Sortable
Demonstrates drag-based interactions using FSM-driven stateful containers:
- Sortable list: drag items to reorder
- Swipe to delete: horizontal drag to dismiss items (stack overlay)
- Sortable grid: 3x3 drag-to-reorder grid
Tip: Some demos are best viewed in a full browser window. Click “Open in a new tab” below for the full experience.
Open in a new tab · View source on GitHub
Stateful API
This example demonstrates the new stateful::() API with:
ctx.event()- Access triggering event in state callbacksctx.use_signal()- Scoped signals for local statectx.use_animated_value()- Spring-animated values
Tip: Some demos are best viewed in a full browser window. Click “Open in a new tab” below for the full experience.
Open in a new tab · View source on GitHub
End-to-end 3D demo wiring Blinc’s SceneKit3D renderer up to
blinc_canvas_kit::SceneKit3D— the camera + light + mesh dispatch front-end used by any Blinc app that wants to drop 3D content into acanvas(). Same primitive demos use for a single spinning cube scale up to a full character rig unchanged.blinc_gltf— glTF 2.0 loader. Parses the file tree once at startup into aGltfScene(meshes, nodes, skeletons, animation clips) that the demo holds behind anArc<Mutex<>>and borrows per frame.blinc_skeleton— runtime poser.animate_scene_nodessamples the clip’s TRS channels into the live node tree;scene_skinning_datawalks the posed tree to build the joint matrices the mesh shader consumes;animate_scene_morph_weightsdrives per-node blend-shape weights for facial expression.
The asset is “The Strangler” by Jungle Jim (CC-BY-4.0;
https://sketchfab.com/3d-models/the-strangler-06d56efabf7445e89bb1bf41a99d08cc),
shipped in the repo for offline reproducibility. Full
attribution lives alongside the asset in
examples/.../assets/3d/the_strangler/license.txt.
Per-frame flow:
animate_scene_nodes(&mut scene, anim, t)— writes sampled TRS onto scene nodesscene_skinning_data(&scene, &skeleton)— returnsSkinningData(joint world matrices × inverse-bind)animate_scene_morph_weights(anim, t)— returns aHashMap<node_index, Vec<f32>>of current weights- For each drawable node: shallow-clone its
MeshData(Arc<Vec<_>>inners → refcount bumps, no vertex copy), stamp the frame’s skinning + morph_weights, dispatch viaDrawContext::draw_mesh_data.
Ordering (OPAQUE before BLEND) is enforced framework-side in
blinc_app::dispatch_pending_meshes, so the demo submits in
scene-graph order without its own sort.
cargo run -p blinc_app_examples --example strangler_demo \
--features windowed --release
Tip: Some demos are best viewed in a full browser window. Click “Open in a new tab” below for the full experience.
Open in a new tab · View source on GitHub
Unified Styling API
Demonstrates all styling approaches in Blinc:
css!macro: CSS-like syntax with hyphenated property namesstyle!macro: Rust-friendly syntax with underscored namesElementStylebuilder: Programmatic style construction- CSS Parser: Runtime CSS string parsing
All approaches produce ElementStyle - a unified schema for visual properties.
Tip: Some demos are best viewed in a full browser window. Click “Open in a new tab” below for the full experience.
Open in a new tab · View source on GitHub
SVG Animation
Demonstrates SVG animation capabilities:
- CSS transforms on SVG elements (rotate, scale)
- Fill/stroke color animation via @keyframes
- stroke-dasharray/dashoffset line-drawing effect
- Path morphing (d-attribute animation)
- Hamburger Menus: 9 food-themed icons (morph, dash, pulse, orbit)
Tip: Some demos are best viewed in a full browser window. Click “Open in a new tab” below for the full experience.
Open in a new tab · View source on GitHub
Table Builder
This example demonstrates the TableBuilder API for declarative table creation.
Tip: Some demos are best viewed in a full browser window. Click “Open in a new tab” below for the full experience.
Open in a new tab · View source on GitHub
Tabler Icons
Showcases outline and filled icons from the blinc_tabler_icons crate.
Tip: Some demos are best viewed in a full browser window. Click “Open in a new tab” below for the full experience.
Open in a new tab · View source on GitHub
Minimal text positioning test
Tests that text is correctly centered within parent containers.
Tip: Some demos are best viewed in a full browser window. Click “Open in a new tab” below for the full experience.
Open in a new tab · View source on GitHub
Text Input Widgets
Demonstrates ready-to-use text input and text area elements using the layout API.
Tip: Some demos are best viewed in a full browser window. Click “Open in a new tab” below for the full experience.
Open in a new tab · View source on GitHub
KHR_texture_transform
Loads Poly Haven’s marble_cliff_02 asset (CC0) — a displaced
rock chunk with a tiling PBR material — and showcases the
KHR_texture_transform glTF extension support added in
blinc_core::TextureTransform + blinc_gpu::mesh_pipeline +
blinc_gltf::parse_material.
The asset’s glTF JSON was patched to include
"extensions": { "KHR_texture_transform": { "scale": [3, 3] } }
on every texture binding, so parse_material reads a 3× tile
transform and the shader multiplies UVs accordingly before every
sample. Press T to toggle the transform off for a side-by-side
comparison — toggling swaps between the parsed Material and a
clone with texture_transform: None, exercising the shader’s
identity path.
Tip: Some demos are best viewed in a full browser window. Click “Open in a new tab” below for the full experience.
Open in a new tab · View source on GitHub
Theme System
This example demonstrates the Blinc theming system capabilities:
- Light/dark mode switching with smooth transitions
- Semantic color tokens (primary, secondary, success, error, etc.)
- Typography tokens (font sizes, weights)
- Spacing tokens (4px-based scale)
- Border radius tokens
- Platform-native theme detection
Tip: Some demos are best viewed in a full browser window. Click “Open in a new tab” below for the full experience.
Open in a new tab · View source on GitHub
Timeline Animation
This example demonstrates timeline-based animations using the stateful API:
- Ping-pong animations using
use_keyframeswith fluent builder - Multiple animated values with staggered delays
- Continuous looping animations with easing
Tip: Some demos are best viewed in a full browser window. Click “Open in a new tab” below for the full experience.
Open in a new tab · View source on GitHub
Typography
This example demonstrates typography helpers:
- Headings: h1-h6, heading()
- Inline text: b, span, small, label, muted, p, caption, inline_code
- Font families: system, monospace, serif, sans_serif, custom fonts
For table examples, see table_demo.rs
Tip: Some demos are best viewed in a full browser window. Click “Open in a new tab” below for the full experience.
Open in a new tab · View source on GitHub
Video Player
Demonstrates the video_player widget with blinc_media::VideoPlayer instance and controls.
Tip: Some demos are best viewed in a full browser window. Click “Open in a new tab” below for the full experience.
Open in a new tab · View source on GitHub
Wet Glass
Procedural wet-window effect with real light refraction through water drops.
Uses sample_scene() to read the background and distort it through
procedural Worley-noise drops, streaks, and condensation fog.
Tip: Some demos are best viewed in a full browser window. Click “Open in a new tab” below for the full experience.
Open in a new tab · View source on GitHub
Windowed Application
This example demonstrates how to create a windowed Blinc application using the platform abstraction layer with a colorful music-player style background.
Features demonstrated:
Stateful<S>withon_statecallback for reactive state management- Window resize/focus events via context properties
- Image element with hover glow effect
Tip: Some demos are best viewed in a full browser window. Click “Open in a new tab” below for the full experience.
Open in a new tab · View source on GitHub
Web Development
Blinc compiles to wasm32-unknown-unknown and runs inside a <canvas> element via wgpu’s WebGPU backend (with WebGL2 fallback). The same Rust UI code that runs on desktop and mobile runs in the browser, with no source-level changes.
The web target is Tier 2 / preview: it ships, has runnable examples, and exercises the same render / event / state pipelines as the native runners — but a few platform-specific bits (touch input, IME, file dialogs, multi-canvas, accessibility) are deliberately out of scope for the initial cut.
Cross-platform architecture
┌─────────────────────────────────────────────────────────────┐
│ Your Blinc App │
│ (Shared Rust UI code, state, animations) │
└─────────────────────────────┬───────────────────────────────┘
│
┌──────┬───────────────┼───────────────┬──────────┐
│ │ │ │ │
┌───▼──┐ ┌─▼──────┐ ┌─────▼──────┐ ┌─────▼─────┐ ┌──▼──┐
│macOS │ │Windows │ │ Android │ │ iOS │ │ Web │
│ Metal│ │DX12/Vk │ │ Vulkan │ │ Metal │ │WebGPU│
└──────┘ └────────┘ └────────────┘ └───────────┘ └─────┘
The web runner (crates/blinc_app/src/web.rs) is a sibling of the desktop / Android / iOS runners. It owns the same 5-phase frame loop:
- Tick scroll physics —
tree.tick_scroll_physics(now_ms)advances active scroll deceleration and bounce springs - Detect rebuild triggers — polls
tree.needs_rebuild(),take_needs_rebuild(), and the reactive dirty flag - Drain pending Stateful updates — applies queued render-prop and subtree changes from
State::set/State::update - Rebuild or incrementally update —
tree.incremental_update(&element)for normal frames, full rebuild on resize - Render —
surface.get_current_texture()→BlincApp::render_tree(...)→frame.present()
The driver is a requestAnimationFrame chain that fires every browser frame.
Key features
- GPU rendering via WebGPU — same SDF / batching / glass / 3D pipelines as desktop
- Mouse + wheel + keyboard input — routed through the same
EventRouterdesktop uses - Drag gestures — DRAG / DRAG_END events with deltas, same
Statefulmachinery - Reactive state —
BlincContextState,State::set,Stateful::on_stateall work unchanged - Animations — spring physics, keyframes, motion containers all tick from
requestAnimationFrame - Asset fetch —
WebAssetLoader::fetch_bytesfor runtime asset loading instead of bundling - Async setup —
WebApp::run_with_async_setupfor fonts, CSS, or anything else that needs to.awaitbefore the first frame
Browser support
| Browser | Status | Notes |
|---|---|---|
| Chrome / Chromium ≥ 113 | Supported | WebGPU enabled by default |
| Edge ≥ 113 | Supported | Same Chromium engine |
| Safari Technology Preview (pre Tahoe) | Partial (flagged) | Develop → Feature Flags → WebGPU . Pre-sequoia does not support Vertex Storage |
| Safari stable (Tahoe) | Supported | WebGPU enabled by default |
| Firefox (≥ 141 on windows, ≥ 145 on MacOs) | Supported | WebGPU enabled by default |
The runtime falls back to WebGL2 where WebGPU is unavailable, but storage-buffer-dependent pipelines need WebGPU — specifically the SDF aux buffer and any future compute shaders. Plain rendering, text, and SVG work on WebGL2; advanced 3D and particles do not.
Project structure
A typical Blinc web project looks like this:
my-web-app/
├── Cargo.toml # crate-type = ["cdylib", "rlib"]
├── src/
│ └── lib.rs # #[wasm_bindgen(start)] + build_ui
├── fonts/ # Optional: fonts to bundle or fetch
│ └── Inter.ttf
├── index.html # canvas + WebGPU/WebGL2 probe + ES module loader
├── pkg/ # Generated by `wasm-pack build` (gitignored)
│ ├── my_web_app.js
│ └── my_web_app_bg.wasm
└── serve.sh # Static file server (python3 / ruby / npx)
The runnable examples/web_hello, examples/web_scroll, examples/web_drag, and examples/web_assets follow this exact layout — copy any of them as a starting point.
What’s deliberately different from desktop
scroll() defaults to bounce-disabled
The native scroll() widget uses spring-bounce at edges. On wasm32, the default is flipped: scroll() returns a no-bounce config. DOM wheel events have no reliable “gesture ended” phase, and macOS layers ~800ms of OS-level momentum-scroll events on top of the user’s gesture — every workaround for “when did the user finish?” produces either a perceptible bounce lag or a wobble as the spring restarts each time the OS momentum re-overscrolls. Native HTML scrolling has no rubber-band either, except at the page level in iOS / macOS Safari, and that’s owned by the OS, not by anything inside a <canvas>.
Apps that explicitly want bounce on web can opt in via Scroll::with_config(ScrollConfig::default()) or supply their own SharedScrollPhysics to Scroll::with_physics.
Async clipboard
web_sys::Clipboard::write_text / read_text are async-only. Text-editor widgets’ Cmd+C / Cmd+V keybinds still trigger on the keypress, but the clipboard write is fire-and-forget and the read can’t be await-ed inside a synchronous handler.
Single canvas
WebApp::run takes a single canvas ID. Multi-canvas / multi-view setups (e.g. an in-page editor preview alongside the main app) are architecturally supported via the shared ElementRegistry, but no WebApp::run_multi API has shipped yet.
What’s missing (Tier 2 gaps)
| Feature | Status | Notes |
|---|---|---|
| Mouse + wheel + keyboard input | ✅ | Routes through EventRouter |
| Drag gestures | ✅ | DRAG / DRAG_END events with deltas |
| Touch input | Pending | DOM touch* events need conversion to InputEvent::Touch |
| IME composition | Pending | compositionstart / update / end → EventRouter::on_text |
| File dialogs | Pending | rfd doesn’t compile on wasm32; needs <input type="file"> bridge |
| System tray / notifications / global hotkeys | Won’t fix | Browser sandbox doesn’t expose these |
localStorage window-state persistence | Pending | Trivial follow-up using web-sys::Storage |
| Service worker / offline assets | Out of scope | App-level concern |
| Multi-canvas / multi-view | Pending | Architecture supports it; no public API yet |
| A11y (ARIA roles, screen reader) | Pending | Larger architecture discussion — needs DOM mirror or accesskit-html |
Next steps
- Setup & Build — Cargo.toml,
wasm-pack, theindex.htmlHTML+JS scaffold - Examples — walkthrough of the four runnable web examples
- Fonts & Assets — bundled vs fetched fonts, the
WebAssetLoaderAPI
Setup & Build
This page walks through the minimum scaffolding to get a Blinc app running in the browser. The four runnable examples in the repo (examples/web_hello, web_scroll, web_drag, web_assets) all follow this exact layout — copy any of them as a starting point.
Toolchain
You need three things on top of a normal Rust toolchain:
-
The
wasm32-unknown-unknowntargetrustup target add wasm32-unknown-unknown -
wasm-pack— drives the wasm build, runswasm-bindgen, and post-processes the output viawasm-optcargo install wasm-pack -
A static file server —
python3 -m http.serveris fine. Browsers refuse to import wasm modules fromfile://URLs, so even a “static” example has to be served over HTTP.
The repo’s example serve.sh scripts auto-pick the first available server (python3 → python → ruby → npx http-server).
Cargo.toml
[package]
name = "my_web_app"
version = "0.1.0"
edition = "2021"
[lib]
# `cdylib` is what wasm-pack needs to emit a `.wasm` artifact + JS shim.
# `rlib` keeps `cargo check` happy on non-wasm targets.
crate-type = ["cdylib", "rlib"]
# wasm-pack invokes Binaryen's `wasm-opt` as a post-processing step.
# Nightly rustc emits bulk-memory and reference-type ops by default,
# but the wasm-opt bundled with wasm-pack 0.13 needs the corresponding
# feature flags or it errors with "Bulk memory operations require bulk
# memory [--enable-bulk-memory]". Pass them through explicitly.
[package.metadata.wasm-pack.profile.release]
wasm-opt = ['-O', '--all-features']
[package.metadata.wasm-pack.profile.dev]
wasm-opt = false
# Strictly a wasm32 example. Native builds of this crate are
# meaningless — there's no entry point that does anything outside
# the browser. The target gate keeps `cargo build --workspace` from
# trying to link an empty cdylib on macOS / Linux.
[target.'cfg(target_arch = "wasm32")'.dependencies]
blinc_app = { version = "0.4", default-features = false, features = ["web"] }
blinc_layout = { version = "0.4" }
blinc_core = { version = "0.4" }
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
web-sys = { version = "0.3", features = ["console"] }
console_error_panic_hook = "0.1"
tracing = "0.1"
tracing-wasm = "0.2"
The critical bits:
crate-type = ["cdylib", "rlib"]— wasm-pack needscdylibto emit the.wasmartifact. Therlibis optional but letscargo checkwork without--target wasm32-unknown-unknown.features = ["web"]onblinc_app— gates inWebApp,WebApp::run, therequestAnimationFramedriver, and the wasm32-only event listeners. Withoutdefault-features = false, you’d accidentally pull inwinitand the desktop platform crates.[target.'cfg(target_arch = "wasm32")'.dependencies]— every Blinc dep is target-gated so a desktopcargo build --workspacedoesn’t try to compile the web example.
src/lib.rs
#![allow(unused)]
#![cfg(target_arch = "wasm32")]
fn main() {
use blinc_app::web::WebApp;
use blinc_app::windowed::WindowedContext;
use blinc_core::Color;
use blinc_layout::div::{div, Div};
use blinc_layout::text::text;
use wasm_bindgen::prelude::*;
const FONT: &[u8] = include_bytes!("../fonts/Inter.ttf");
/// wasm-bindgen entry point. The `start` attribute makes this run
/// automatically when the browser loads the generated `.js` shim.
#[wasm_bindgen(start)]
pub fn _start() {
// Install the panic hook so any Rust panic shows up in the
// browser console with a stack trace instead of a useless
// `RuntimeError: unreachable executed`.
console_error_panic_hook::set_once();
// Bridge `tracing::*` macros into the browser DevTools console.
// INFO level keeps the per-frame DEBUG lines from the renderer
// out of the console — at 60fps those drown the JS thread.
tracing_wasm::set_as_global_default_with_config(
tracing_wasm::WASMLayerConfigBuilder::new()
.set_max_level(tracing::Level::INFO)
.build(),
);
// `WebApp::run` is `async`, but `#[wasm_bindgen(start)]` can't
// return a future. Spawn it on the wasm-bindgen-futures executor
// instead.
wasm_bindgen_futures::spawn_local(async {
let result = WebApp::run_with_setup(
"blinc-canvas",
// Setup callback runs once between init and the first
// frame. Use it to register fonts (required — the wasm32
// init path skips system font discovery, so the registry
// starts empty), CSS, and any one-shot config.
|app| {
app.load_font_data(FONT.to_vec());
},
build_ui,
)
.await;
if let Err(e) = result {
web_sys::console::error_1(
&format!("WebApp::run failed: {e}").into(),
);
}
});
}
/// User UI builder. Re-invoked by the runner whenever a rebuild is
/// requested.
fn build_ui(_ctx: &mut WindowedContext) -> Div {
div()
.w_full()
.h_full()
.bg(Color::rgba(0.07, 0.07, 0.10, 1.0))
.items_center()
.justify_center()
.child(
text("Hello, WebGPU!")
.size(32.0)
.color(Color::rgba(0.92, 0.92, 0.95, 1.0)),
)
}
}
The two non-obvious bits:
#![cfg(target_arch = "wasm32")]at the top — the rest of the file useswasm-bindgenandweb-sys, which only exist on wasm32. The cfg attribute makes the whole module a no-op when someone runscargo checkfrom a desktop checkout.load_font_data(FONT.to_vec())inside the setup closure — required. The wasm32 init path deliberately skips system font discovery (no filesystem), so without at least one explicitly registered font every text element renders as nothing. See Fonts & Assets for the alternative fetch-based pattern.
index.html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>My Blinc Web App</title>
<style>
html, body { margin: 0; padding: 0; height: 100%; background: #0d0d12; }
body { display: flex; flex-direction: column; }
#blinc-canvas { display: block; width: 100vw; height: 100vh; }
#unsupported {
display: none; position: absolute; inset: 0;
align-items: center; justify-content: center;
flex-direction: column; gap: 12px;
font-family: system-ui, sans-serif; color: #ededf0;
text-align: center; padding: 24px;
}
.no-webgpu #blinc-canvas { display: none; }
.no-webgpu #unsupported { display: flex; }
</style>
</head>
<body>
<canvas id="blinc-canvas"></canvas>
<div id="unsupported">
<strong>WebGPU not available</strong>
<span>This app requires Chrome / Edge 113+ or a browser with WebGPU enabled.</span>
</div>
<script type="module">
// CRITICAL: probe via a *throwaway* canvas, never the canvas
// we hand to wgpu. Calling `getContext("webgl2")` on the live
// canvas locks its context type forever and breaks wgpu's
// surface creation with "canvas already in use".
const hasWebGPU = "gpu" in navigator;
const probeCanvas = document.createElement("canvas");
const hasWebGL2 = !!probeCanvas.getContext("webgl2");
if (!hasWebGPU && !hasWebGL2) {
document.body.classList.add("no-webgpu");
} else {
// wasm-pack `--target web` emits an ES module loader at
// `pkg/<crate>.js` that exports the wasm `init` function.
// Importing it kicks off the loader; the `start` function
// fires automatically.
const { default: init } = await import("./pkg/my_web_app.js");
await init();
}
</script>
</body>
</html>
The throwaway canvas probe is mandatory. The W3C HTML canvas-context spec says calling getContext("webgl2") on a canvas locks its context type to webgl2 forever — every subsequent getContext("webgpu") on the same element returns null, and wgpu’s surface creation fails with “canvas already in use”.
Build commands
# Development build (no wasm-opt, fast iteration)
wasm-pack build --target web --dev
# Release build (wasm-opt -O, smaller and faster)
wasm-pack build --target web --release
# Then serve `pkg/` and `index.html` together
python3 -m http.server 8000
# open http://localhost:8000/
wasm-pack build produces a pkg/ directory containing:
<crate>.js— JS shim (~100 KB) that callswasm-bindgen-generated bindings<crate>_bg.wasm— the actual wasm artifact (typically 6-8 MB pre-strip, smaller afterwasm-opt)<crate>.d.ts— TypeScript bindings (optional)package.json— npm metadata (optional)
pkg/ is regenerated on every build, so it should be in .gitignore:
# wasm-pack output — regenerated by `wasm-pack build --target web --release`.
pkg/
Bundle size
A minimal Blinc app is ~6-8 MB pre-strip. Where the bytes go:
- Renderer + WGSL shaders + wgpu — ~3 MB
- rustybuzz / unicode-bidi / unicode-linebreak (text shaping) — ~600 KB
- resvg / tiny-skia (SVG rasterization) — ~700 KB
- Layout (taffy + flexbox) — ~200 KB
- Reactive graph + state hooks + Stateful machinery — ~400 KB
- Bundled font (if any) — ~750 KB per typical TTF
For tighter bundles, see the Fonts & Assets chapter on fetching fonts at runtime instead of bundling them — the web_assets example is 612 KB smaller than web_hello purely because Arial is fetched on first load instead of baked into the wasm.
Next
- Examples — walkthrough of the four runnable web examples
- Fonts & Assets — bundled vs fetched fonts,
WebAssetLoaderAPI
Examples
The repo ships four runnable web examples under examples/. Each is a single-file lib.rs under 250 lines, with the same index.html + serve.sh scaffolding from the Setup chapter. They’re the canonical reference for every pattern in this book — when in doubt, copy from one of these.
| Example | What it demonstrates | Lines |
|---|---|---|
web_hello | Minimum: canvas + bundled font + one Div | ~130 |
web_scroll | Wheel input → scroll widget → physics tick | ~200 |
web_drag | Mouse drag → Stateful + State::set → visual update | ~245 |
web_assets | Fetch fonts at runtime via WebAssetLoader | ~135 |
web_hello — the smallest possible app
The “Hello, WebGPU!” canonical example. Centered text on a dark background, no input handlers, no animations, no state. If this draws on a real canvas in a real browser, the entire wgpu / wasm-bindgen / requestAnimationFrame / WebApp::run pipeline is alive end-to-end.
#![allow(unused)]
fn main() {
use blinc_app::web::WebApp;
use blinc_layout::div::{div, Div};
use blinc_layout::text::text;
use blinc_core::Color;
use wasm_bindgen::prelude::*;
const ARIAL: &[u8] = include_bytes!("../fonts/Arial.ttf");
#[wasm_bindgen(start)]
pub fn _start() {
console_error_panic_hook::set_once();
wasm_bindgen_futures::spawn_local(async {
WebApp::run_with_setup(
"blinc-canvas",
|app| { app.load_font_data(ARIAL.to_vec()); },
build_ui,
).await.unwrap();
});
}
fn build_ui(_ctx: &mut blinc_app::windowed::WindowedContext) -> Div {
div()
.w_full().h_full()
.bg(Color::rgba(0.07, 0.07, 0.10, 1.0))
.items_center().justify_center()
.child(
text("Hello, WebGPU!")
.size(32.0)
.color(Color::WHITE),
)
}
}
Read the full example: examples/web_hello/src/lib.rs.
web_scroll — wheel input + scroll physics
A vertical list of 24 cards inside a scroll() container. Demonstrates:
- Wheel input routes through
EventRouter::on_scroll_nestedand is dispatched viaRenderTree::dispatch_scroll_chain(which walks the chain of scroll containers from leaf to root for nested-scroll consumption) - The scroll widget’s per-frame physics tick (
tree.tick_scroll_physics(now_ms)) runs every rAF frame, advancing any active deceleration - Click events also fire on cards via
on_clickhandlers - The no-bounce default for wasm32 —
scroll()returns aScrollConfig::no_bounce()config because DOM wheel events have no “gesture ended” phase to drive bounce-back from. See the Overview for the rationale.
#![allow(unused)]
fn main() {
const CARD_COUNT: usize = 24;
let mut content = div().w_full().flex_col().p_px(20.0).gap_px(12.0);
for idx in 0..CARD_COUNT {
let label = format!("Card {}", idx + 1);
let card_index = idx + 1;
content = content.child(
div()
.w_full().h_fit()
.bg(Color::rgba(0.16, 0.16, 0.21, 1.0))
.rounded(12.0).p_px(16.0)
.child(text(&label).size(20.0).color(Color::WHITE))
.on_click(move |_| {
web_sys::console::log_1(
&format!("clicked card #{card_index}").into(),
);
}),
);
}
div()
.w_full().h_full()
.bg(Color::rgba(0.07, 0.07, 0.10, 1.0))
.child(
scroll()
.w_full()
.h(ctx.height - 96.0)
.child(content)
.on_scroll(|e| {
tracing::info!(
"scroll delta=({:.1}, {:.1})",
e.scroll_delta_x, e.scroll_delta_y,
);
}),
)
}
Full example: examples/web_scroll/src/lib.rs.
web_drag — gesture interaction with Stateful + State
A single draggable card that lifts (opacity dip + raised z-index) and follows the cursor while held, then snaps back on release. Structurally identical to the sortable_list_section in the desktop sortable_demo.rs — same DragFSM, same Stateful::on_state recipe, same handler chain, same code that runs on Android and iOS:
#![allow(unused)]
fn main() {
use blinc_layout::stateful::{stateful_with_key, StateTransitions};
use blinc_core::reactive::State;
use blinc_core::context_state::BlincContextState;
use blinc_core::events::event_types;
#[derive(Default, Clone, Copy, PartialEq, Eq, Hash, Debug)]
enum DragFSM {
#[default]
Idle,
Dragging,
}
impl StateTransitions for DragFSM {
fn on_event(&self, event: u32) -> Option<Self> {
match (self, event) {
(DragFSM::Idle, event_types::DRAG) => Some(DragFSM::Dragging),
(DragFSM::Dragging, event_types::DRAG_END) => Some(DragFSM::Idle),
(DragFSM::Dragging, event_types::POINTER_UP) => Some(DragFSM::Idle),
_ => None,
}
}
}
fn draggable_card() -> Stateful<DragFSM> {
let blinc = BlincContextState::get();
let offset: State<(f32, f32)> =
blinc.use_state_keyed("card_offset", || (0.0, 0.0));
let offset_for_drag = offset.clone();
let offset_for_end = offset.clone();
stateful_with_key::<DragFSM>("draggable-card")
.deps([offset.signal_id()])
.on_state(move |ctx| {
let (ox, oy) = offset.get();
let dragging = matches!(ctx.state(), DragFSM::Dragging);
let mut card = div()
.w(220.0).h(120.0)
.bg(Color::rgba(0.32, 0.55, 0.92, 1.0))
.rounded(16.0)
.items_center().justify_center()
.child(text("Drag me").size(20.0).color(Color::WHITE));
if dragging {
card = card
.transform(Transform::translate(ox, oy))
.opacity(0.85)
.z_index(100);
}
card
})
.on_drag(move |e| {
offset_for_drag.set((e.drag_delta_x, e.drag_delta_y));
})
.on_drag_end(move |_e| {
offset_for_end.set((0.0, 0.0));
})
}
}
The framework’s DragFSM Stateful state transitions automatically as DRAG / DRAG_END events fire — ctx.state() reads the current FSM state without you having to maintain a parallel bool. The visual offset lives in its own State<(f32, f32)> cell because it changes far more frequently than the FSM (every drag tick vs only at gesture boundaries).
Full example: examples/web_drag/src/lib.rs.
web_assets — fetched font instead of bundled
Demonstrates the WebApp::run_with_async_setup + WebAssetLoader::fetch_bytes pattern. The font isn’t bundled inside the wasm artifact; it’s fetched at runtime as a separate static asset that the browser caches independently. This example’s wasm is 612 KB smaller than web_hello purely because Arial is no longer baked into the bundle:
#![allow(unused)]
fn main() {
use blinc_app::web::WebApp;
use blinc_app::BlincError;
use blinc_platform_web::WebAssetLoader;
const FONT_URL: &str = "fonts/Arial.ttf";
#[wasm_bindgen(start)]
pub fn _start() {
console_error_panic_hook::set_once();
wasm_bindgen_futures::spawn_local(async {
WebApp::run_with_async_setup(
"blinc-canvas",
// The `Box::pin(async move { ... })` ceremony is the
// stable-Rust workaround for the lack of `async FnOnce`.
// Once async closures stabilize this drops back to
// `|app| async move { ... }`.
|app| Box::pin(async move {
let bytes = WebAssetLoader::fetch_bytes(FONT_URL)
.await
.map_err(|e| BlincError::Platform(e.to_string()))?;
app.load_font_data(bytes);
Ok(())
}),
build_ui,
).await.unwrap();
});
}
}
See the Fonts & Assets chapter for the full picture, including the recommended bundled-fallback-then-fetched-real-font pattern for production apps.
Full example: examples/web_assets/src/lib.rs.
Running an example locally
git clone https://github.com/project-blinc/Blinc
cd Blinc/examples/web_hello
wasm-pack build --target web --release
./serve.sh
# open http://localhost:8000/
./serve.sh picks the first available static-file server on your system (python3 → python → ruby → npx http-server) and runs it from the example directory. If pkg/ doesn’t exist yet (i.e. you forgot to wasm-pack build first), the script exits with a hint.
Fonts & Assets
Browsers can’t hand wgpu their system fonts — those live in the compositor’s 2D pipeline, not in the WebGPU pipeline. Blinc rasterizes glyphs natively via swash, which needs the actual TTF/OTF bytes in wasm memory. The wasm32 init path deliberately skips system font discovery (no filesystem), so the font registry starts empty.
Without a registered font, every text element fails to shape glyphs and renders as nothing. Loading at least one font is mandatory.
Two patterns
Pattern 1: bundled font (include_bytes!)
The simplest option. Font bytes ship inside the wasm artifact via include_bytes!. Adds ~750 KB to the bundle per typical TTF, but the font is “available” the moment WebApp::new returns — no extra network round-trip, no fallback flicker.
#![allow(unused)]
fn main() {
use blinc_app::web::WebApp;
use wasm_bindgen::prelude::*;
const ARIAL_TTF: &[u8] = include_bytes!("../fonts/Arial.ttf");
#[wasm_bindgen(start)]
pub fn _start() {
console_error_panic_hook::set_once();
wasm_bindgen_futures::spawn_local(async {
WebApp::run_with_setup(
"blinc-canvas",
// Sync setup callback — runs once between init and the
// first frame. Just hands the font bytes to the registry.
|app| {
let faces = app.load_font_data(ARIAL_TTF.to_vec());
tracing::info!("registered {faces} font face(s)");
},
build_ui,
)
.await
.unwrap();
});
}
}
load_font_data returns the number of font faces registered. Most TTFs have a single face; TTC collections have several.
This is the pattern web_hello, web_scroll, and web_drag use. It’s the right choice for prototypes, demos, and any app that ships a single small font.
Pattern 2: fetched font (run_with_async_setup)
Recommended for real apps that ship more than one font, or for any font over a few hundred KB. The font lives next to index.html as a static asset; the browser caches it independently across reloads, and the wasm artifact stays small.
#![allow(unused)]
fn main() {
use blinc_app::web::WebApp;
use blinc_app::BlincError;
use blinc_platform_web::WebAssetLoader;
use wasm_bindgen::prelude::*;
#[wasm_bindgen(start)]
pub fn _start() {
console_error_panic_hook::set_once();
wasm_bindgen_futures::spawn_local(async {
WebApp::run_with_async_setup(
"blinc-canvas",
// The `Box::pin(async move { ... })` ceremony is the
// stable-Rust workaround for the lack of `async FnOnce`.
// Once async closures stabilize, this drops back to
// `|app| async move { ... }`.
|app| Box::pin(async move {
let bytes = WebAssetLoader::fetch_bytes("fonts/Inter.ttf")
.await
.map_err(|e| BlincError::Platform(e.to_string()))?;
app.load_font_data(bytes);
Ok(())
}),
build_ui,
)
.await
.unwrap();
});
}
}
run_with_async_setup is the async sibling of run_with_setup. The setup closure runs once between init and the first frame. The runner awaits the returned future synchronously before installing the UI builder, so by the time the first rAF tick fires, the font is already in the registry.
WebAssetLoader::fetch_bytes is a one-shot helper that fetches a single URL and returns the raw bytes. It does not keep a copy in the loader cache — the bytes have a downstream owner (the font registry, which takes ownership in load_font_data), and caching them on the loader side too would just double the memory.
This is the pattern the web_assets example uses. The wasm artifact is 612 KB smaller than web_hello purely because Arial is no longer baked into the bundle.
Pattern 3: bundled fallback + fetched main (recommended for production)
The first-frame timing of pattern 2 has one downside: the canvas is blank until the fetch resolves. For apps that care about FOIT/FOUT, ship a tiny system-ish fallback font bundled and fetch the real font asynchronously. The fallback renders the first frame; the real font replaces it the moment it lands:
#![allow(unused)]
fn main() {
use blinc_app::web::WebApp;
use blinc_app::BlincError;
use blinc_platform_web::WebAssetLoader;
// Tiny system-ish fallback bundled inside the wasm — ~50-100 KB.
const FALLBACK_TTF: &[u8] = include_bytes!("../fonts/SystemFallback.ttf");
WebApp::run_with_async_setup(
"blinc-canvas",
|app| Box::pin(async move {
// 1. Bundled fallback first — first frame renders text immediately.
app.load_font_data(FALLBACK_TTF.to_vec());
// 2. Fetch the real font in parallel — replaces the fallback
// once it lands. The font registry handles the override
// automatically by face name + weight.
let inter = WebAssetLoader::fetch_bytes("fonts/Inter.ttf")
.await
.map_err(|e| BlincError::Platform(e.to_string()))?;
app.load_font_data(inter);
Ok(())
}),
build_ui,
)
.await
}
This is the production-grade pattern. The bundled fallback keeps the wasm artifact moderate-sized (a real 750 KB font is replaced by a 50-100 KB stripped subset), the first frame renders immediately, and the high-quality font replaces the fallback transparently.
Multiple fonts
load_font_data is additive — call it once per font:
#![allow(unused)]
fn main() {
WebApp::run_with_async_setup(
"blinc-canvas",
|app| Box::pin(async move {
let inter = WebAssetLoader::fetch_bytes("fonts/Inter-Regular.ttf").await?;
app.load_font_data(inter);
let inter_bold = WebAssetLoader::fetch_bytes("fonts/Inter-Bold.ttf").await?;
app.load_font_data(inter_bold);
let mono = WebAssetLoader::fetch_bytes("fonts/JetBrainsMono.ttf").await?;
app.load_font_data(mono);
Ok(())
}),
build_ui,
).await
}
For lots of fonts, parallelize via futures::join! or wasm_bindgen_futures::spawn_local so the network round-trips overlap:
#![allow(unused)]
fn main() {
let (inter_regular, inter_bold, mono) = futures::join!(
WebAssetLoader::fetch_bytes("fonts/Inter-Regular.ttf"),
WebAssetLoader::fetch_bytes("fonts/Inter-Bold.ttf"),
WebAssetLoader::fetch_bytes("fonts/JetBrainsMono.ttf"),
);
app.load_font_data(inter_regular?);
app.load_font_data(inter_bold?);
app.load_font_data(mono?);
}
Other assets
WebAssetLoader::preload(urls) is the API for general-purpose asset preloading. Unlike fetch_bytes, it stores fetched bytes in the loader’s HashMap so later synchronous AssetLoader::load(...) calls can resolve them:
#![allow(unused)]
fn main() {
use blinc_platform_web::WebAssetLoader;
use blinc_platform::assets::AssetPath;
let loader = WebAssetLoader::new();
// Fetch + cache
loader.preload(&[
"images/logo.png",
"icons/menu.svg",
"data/translations.json",
]).await?;
// Synchronous lookup later (e.g. from a render handler)
let logo_bytes = loader.load(&AssetPath::Relative("images/logo.png".into()))?;
}
This is the pattern Blinc’s image loader, SVG loader, and any custom asset consumer expects: bytes are pre-loaded into a cache up front via preload, and downstream consumers do synchronous load(...) calls that resolve from the cache. The synchronous AssetLoader::load call panics if the asset isn’t in the cache — the trait is sync because the rest of Blinc is sync, and the browser doesn’t let you block on I/O from the main thread, so the only way to satisfy the contract is to pre-fetch everything you’ll need.
For one-shot bytes that have a downstream owner (like fonts), use fetch_bytes. For bytes that need synchronous lookup later (images, SVG, JSON config), use preload + load.
Why no system fonts?
Browser-provided fonts (system fonts, @font-face declarations, the FontFace API) are NOT accessible from wgpu. They live in the browser’s compositor and 2D-canvas pipeline, not in the WebGPU pipeline. Blinc rasterizes glyphs in wasm via swash, which operates on TTF/OTF bytes — and those bytes have to come from somewhere the wasm runtime can read, which on the browser means either the wasm artifact itself or a fetch() response.
The Local Font Access API (Working Draft) would allow Blinc to enumerate system fonts and request their bytes, but it’s only shipped in Chrome (gated behind a permission prompt) and isn’t widely supported. Until that changes, fetched-or-bundled is the only path.
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::<S>() to create elements with automatic hover/press state transitions:
#![allow(unused)]
fn main() {
use blinc_layout::stateful::stateful;
fn hoverable_card() -> impl ElementBuilder {
stateful::<ButtonState>()
.p(16.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.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().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 full-featured CSS engine that lets you style your UI with familiar CSS syntax. Write stylesheets with selectors, animations, transitions, filters, 3D transforms, and more — then apply them with a single ctx.add_css() call.
Quick Start
use blinc_app::prelude::*;
use blinc_app::windowed::{WindowedApp, WindowedContext};
fn main() -> Result<()> {
let mut css_loaded = false;
WindowedApp::run(WindowConfig::default(), move |ctx| {
if !css_loaded {
ctx.add_css(r#"
#card {
background: linear-gradient(135deg, #667eea, #764ba2);
border-radius: 16px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
padding: 24px;
transition: transform 0.3s ease, box-shadow 0.3s ease;
}
#card:hover {
transform: scale(1.03);
box-shadow: 0 12px 48px rgba(102, 126, 234, 0.5);
}
"#);
css_loaded = true;
}
build_ui(ctx)
})
}
fn build_ui(_ctx: &WindowedContext) -> impl ElementBuilder {
div().id("card").child(text("Hello, CSS!").size(20.0).color(Color::WHITE))
}
Rust code defines structure. CSS defines style.
Table of Contents
- Selectors
- Visual Properties
- Layout Properties
- Text & Typography
- Transforms
- Transitions
- Animations
- Filters
- Backdrop Filters & Glass Effects
- Clip Path
- Mask Image
- SVG Styling
- 3D Shapes & Lighting
- CSS Variables
- Theme Integration
- Form Styling
- Length Units
- Error Handling
- Scoped Style Macros
- How It Works
- Property Comparison
Selectors
Blinc supports a wide range of CSS selectors — from simple IDs to complex combinators.
ID Selectors
The most common way to target elements. Attach an id in Rust, then style it in CSS:
#![allow(unused)]
fn main() {
div().id("card") // Rust
}
#card { background: #3b82f6; }
Class Selectors
Assign CSS classes with .class() in Rust:
#![allow(unused)]
fn main() {
div().class("icon-wrapper")
}
.icon-wrapper {
border-radius: 24px;
backdrop-filter: blur(12px);
transition: transform 0.2s ease;
}
.icon-wrapper:hover {
transform: scale(1.12);
}
Type / Tag Selectors
Target elements by tag name (primarily used for SVG sub-elements):
svg { stroke: #ffffff; fill: none; stroke-width: 2.5; }
path { stroke: #8b5cf6; stroke-width: 5; }
circle { fill: #f3e8ff; stroke: #a78bfa; }
rect { fill: #fef3c7; stroke: #f59e0b; }
Universal Selector
* { opacity: 1.0; }
Pseudo-Classes (States)
Interactive states are matched automatically based on user input:
#button:hover { transform: scale(1.02); }
#button:active { transform: scale(0.98); }
#button:focus { box-shadow: 0 0 0 3px #3b82f6; }
#button:disabled { opacity: 0.5; }
#checkbox:checked { background: #3b82f6; }
Structural Pseudo-Classes
.item:first-child { border-radius: 12px 12px 0 0; }
.item:last-child { border-radius: 0 0 12px 12px; }
.item:only-child { border-radius: 12px; }
.item:nth-child(2) { background: #f0f0f0; }
.item:nth-last-child(1) { font-weight: bold; }
.item:first-of-type { color: red; }
.item:last-of-type { color: blue; }
.item:nth-of-type(3) { opacity: 0.5; }
.item:only-of-type { border: 2px solid green; }
:empty { display: none; }
:root { --primary: #3b82f6; }
Functional Pseudo-Classes
:not(.hidden) { opacity: 1; }
:is(#card, .panel) { border-radius: 12px; }
:where(.btn, .link) { cursor: pointer; }
Pseudo-Elements
#input::placeholder { color: #64748b; }
#text::selection { background: #3b82f6; }
Combinators
Chain selectors for precise targeting:
/* Child combinator — direct children only */
#parent > .child { padding: 8px; }
/* Descendant combinator — any depth */
#list .item { margin: 4px; }
/* Adjacent sibling — next element */
.trigger:hover + .target { opacity: 1; }
/* General sibling — any following sibling */
.trigger:hover ~ .item { background: #e0e0e0; }
Complex Selectors
Combine any of the above:
#card:hover > .title { color: #ffffff; }
#list .item:last-child { border-bottom: none; }
.icon-wrapper:hover #pause { fill: rgba(0, 0, 0, 0.7); }
#progress:hover #time-left { opacity: 1; }
Visual Properties
Background
Supports solid colors, gradients, and image URLs:
/* Solid colors */
#el { background: #3b82f6; }
#el { background: rgb(59, 130, 246); }
#el { background: rgba(255, 255, 255, 0.15); }
/* Linear gradient */
#el { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); }
#el { background: linear-gradient(to right, red, blue); }
#el { background: linear-gradient(to bottom right, #fff, #000); }
/* Radial gradient */
#el { background: radial-gradient(circle, red, blue); }
#el { background: radial-gradient(circle at 25% 75%, red, blue); }
/* Conic gradient */
#el { background: conic-gradient(from 45deg, red, yellow, green, blue, red); }
/* Background image */
#el { background: url("path/to/image.jpg"); }
Border Radius
#card { border-radius: 12px; }
#avatar { border-radius: 50px; } /* Circle */
#card { border-radius: theme(radius-lg); } /* Theme token */
Corner Shape
Controls the shape of rounded corners using superellipse exponents. Instead of the standard circular arc, you can create beveled, squircle, scooped, or notched corners.
/* Uniform shape for all corners */
#card { corner-shape: 2; } /* Squircle (smoother than circular) */
#card { corner-shape: 0; } /* Bevel (straight diagonal cut) */
#card { corner-shape: -1; } /* Scoop (concave inward curve) */
/* Functional syntax */
#card { corner-shape: superellipse(2); } /* Same as corner-shape: 2 */
Value reference:
| Value | Shape | Description |
|---|---|---|
1 | Round | Standard circular arc (default) |
0 | Bevel | Straight diagonal cut |
2 | Squircle | Smoother than circular (iOS-style) |
-1 | Scoop | Concave inward curve |
100 | Square | Sharp corner (ignores border-radius) |
-100 | Notch | Sharp 90-degree inward notch |
Rust builder equivalents:
#![allow(unused)]
fn main() {
div().corner_shape(2.0) // Uniform squircle
div().corner_shapes(0.0, 2.0, 2.0, 0.0) // Per-corner: bevel TL, squircle others
div().corner_squircle() // Preset: squircle
div().corner_bevel() // Preset: bevel
div().corner_scoop() // Preset: scoop
}
Corner shape is animatable via transitions and keyframes:
#card {
border-radius: 20px;
corner-shape: 1;
transition: corner-shape 0.3s ease;
}
#card:hover {
corner-shape: 2; /* Morph from round to squircle on hover */
}
Border
#el { border-width: 2px; border-color: #3b82f6; }
#el { border-width: 1.5px; border-color: rgba(255, 255, 255, 0.5); }
Box Shadow
#card { box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3); }
#glow { box-shadow: 0 0 40px rgba(139, 92, 246, 0.7); }
#card { box-shadow: none; }
Text Shadow
#heading { text-shadow: 3px 3px 0px rgba(255, 68, 68, 1.0); }
Outline
#el { outline: 3px solid #f59e0b; }
#el { outline-offset: 6px; }
#el { outline-width: 2px; outline-color: #ef4444; }
Opacity
#el { opacity: 0.75; }
Visibility
#el { visibility: hidden; }
Z-Index & Render Layer
#overlay { z-index: 10; }
#el { render-layer: foreground; } /* foreground | background | glass */
Layout Properties
All standard flexbox layout properties can be set from CSS:
Sizing
#card {
width: 380px;
height: 200px;
min-width: 100px;
max-width: 600px;
}
/* Percentage values */
#full { width: 100%; }
/* Auto sizing */
#auto { width: auto; }
Spacing
#card {
padding: 24px;
padding: 6px 8px; /* vertical horizontal */
padding: 8px 12px 16px; /* top horizontal bottom */
padding: 8px 12px 16px 4px; /* top right bottom left */
margin: 16px;
gap: 20px;
}
Flexbox
#container {
display: flex;
flex-direction: row; /* row | column | row-reverse | column-reverse */
flex-wrap: wrap; /* wrap | nowrap */
align-items: center; /* center | start | end | stretch | baseline */
justify-content: space-between; /* center | start | end | space-between | space-around | space-evenly */
gap: 16px;
}
#item {
flex-grow: 1;
flex-shrink: 0;
align-self: center;
}
Positioning
#el {
position: absolute; /* static | relative | absolute | fixed | sticky */
top: 10px;
right: 0;
bottom: 0;
left: 10px;
inset: 0; /* shorthand for all four */
}
Overflow
#scroll { overflow: scroll; }
#clip { overflow: clip; }
#el { overflow-x: scroll; overflow-y: hidden; }
Overflow Fade
Replaces the hard clip at overflow boundaries with a smooth fade-to-transparent ramp. Each edge can have an independent fade distance in pixels.
/* Uniform: all 4 edges fade over 24px */
#scroll { overflow-fade: 24px; }
/* Vertical + horizontal: top/bottom 32px, left/right 0 */
#scroll { overflow-fade: 32px 0px; }
/* Per-edge: top, right, bottom, left */
#scroll { overflow-fade: 24px 0px 24px 0px; }
Rust builder equivalents:
#![allow(unused)]
fn main() {
div().overflow_fade(24.0) // Uniform
div().overflow_fade_y(32.0) // Vertical only (top + bottom)
div().overflow_fade_x(16.0) // Horizontal only (left + right)
div().overflow_fade_edges(24.0, 0.0, 24.0, 0.0) // Per-edge: top, right, bottom, left
}
Overflow fade is animatable — combine with transitions for hover-triggered soft edges:
#container {
overflow: clip;
overflow-fade: 0px;
transition: overflow-fade 0.3s ease;
}
#container:hover {
overflow-fade: 32px;
}
Display
#hidden { display: none; }
#flex { display: flex; }
#block { display: block; }
Text & Typography
#text {
color: #ffffff;
font-size: 20px;
font-weight: 700; /* 100-900 or thin/light/normal/bold/black */
line-height: 1.5;
letter-spacing: 0.5px;
text-align: center; /* left | center | right */
text-decoration: underline; /* none | underline | line-through */
text-decoration-color: #ff0000;
text-decoration-thickness: 2px;
text-overflow: ellipsis; /* clip | ellipsis */
white-space: nowrap; /* normal | nowrap | pre | pre-wrap */
}
Transforms
2D Transforms
#el { transform: rotate(15deg); }
#el { transform: scale(1.15); }
#el { transform: scale(1.5, 0.8); } /* non-uniform */
#el { transform: translate(10px, 20px); }
#el { transform: translateX(10px); }
#el { transform: translateY(20px); }
#el { transform: skewX(-8deg); }
#el { transform: skewY(12deg); }
#el { transform: skew(10deg, 5deg); }
Compound Transforms
Chain multiple transforms in a single property:
#el { transform: rotate(15deg) scale(1.15); }
#el { transform: scale(0.9) translateY(20px); }
Transform Origin
#el { transform-origin: center; }
#el { transform-origin: left top; }
#el { transform-origin: right bottom; }
#el { transform-origin: 50% 50%; }
Transitions
Smoothly animate property changes on state transitions (e.g., hover):
#card {
background: #1e293b;
transform: scale(1.0);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
transition: transform 0.3s ease, box-shadow 0.3s ease, background 0.3s ease;
}
#card:hover {
transform: scale(1.05);
box-shadow: 0 8px 32px rgba(59, 130, 246, 0.5);
background: #334155;
}
Transition Syntax
/* Shorthand */
transition: property duration timing-function delay;
/* Multiple properties */
transition: transform 0.3s ease, opacity 0.3s ease, box-shadow 0.25s ease;
/* All properties */
transition: all 0.3s ease;
/* Individual properties */
transition-property: transform;
transition-duration: 300ms;
transition-timing-function: ease-in-out;
transition-delay: 100ms;
Animatable Properties
Almost every visual and layout property can be transitioned:
- Visual:
opacity,background,border-color,border-width,border-radius,corner-shape,box-shadow,text-shadow,outline-color,outline-width - Transform:
transform(rotate, scale, translate, skew) - Layout:
width,height,padding,margin,gap,min-width,max-width,min-height,max-height,top,left,flex-grow - Filters:
filter,backdrop-filter - Clip & Overflow:
clip-path,overflow-fade - SVG:
fill,stroke,stroke-width,stroke-dashoffset - Mask:
mask-image - 3D:
rotate-x,rotate-y,perspective,translate-z
Timing Functions
| Function | Description |
|---|---|
ease | Slow start and end (default) |
linear | Constant speed |
ease-in | Slow start |
ease-out | Slow end |
ease-in-out | Slow start and end |
Layout Transitions
Layout properties animate with automatic layout recalculation:
#panel {
width: 120px;
height: 60px;
padding: 8px;
transition: width 0.4s ease, height 0.4s ease, padding 0.3s ease;
}
#panel:hover {
width: 280px;
height: 120px;
padding: 24px;
}
Animations
@keyframes
Define multi-step animations:
@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.7; transform: scale(1.05); }
}
@keyframes gradient-cycle {
0% { background: linear-gradient(90deg, #d0d0d0, #e0e0e0, #ffffff); }
33% { background: linear-gradient(90deg, #d0d0d0, #ffffff, #d0d0d0); }
66% { background: linear-gradient(90deg, #ffffff, #e0e0e0, #d0d0d0); }
100% { background: linear-gradient(90deg, #d0d0d0, #e0e0e0, #ffffff); }
}
Animation Property
/* Shorthand */
#el { animation: pulse 2s ease-in-out infinite; }
/* Full shorthand */
#el { animation: slide-in 300ms ease-out 100ms 1 normal forwards; }
/* name duration timing delay count direction fill-mode */
/* Individual properties */
#el {
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 */
}
Animatable Keyframe Properties
All these properties can be used inside @keyframes:
opacity,transform,background,border-color,border-width,border-radiusbox-shadow,text-shadow,outline,color,font-sizewidth,height,padding,margin,gap,min-width,max-widthfilter(blur, brightness, contrast, etc.)backdrop-filterclip-pathfill,stroke,stroke-width,stroke-dasharray,stroke-dashoffsetd(SVG path morphing)rotate-x,rotate-y,perspective,translate-zlight-direction,light-intensity,ambient,specular
Automatic Animation
Elements with an animation property in the stylesheet start animating automatically:
@keyframes card-enter {
from { opacity: 0; transform: scale(0.95); }
to { opacity: 1; transform: scale(1); }
}
#card { animation: card-enter 300ms ease-out; }
#![allow(unused)]
fn main() {
div().id("card").child(content()) // Animates on first render!
}
Filters
Apply visual effects to elements:
#el { filter: grayscale(100%); }
#el { filter: sepia(100%); }
#el { filter: invert(100%); }
#el { filter: brightness(150%); }
#el { filter: contrast(200%); }
#el { filter: saturate(300%); }
#el { filter: hue-rotate(90deg); }
#el { filter: blur(4px); }
/* Combined filters */
#el { filter: grayscale(50%) brightness(120%) contrast(110%); }
/* Filter transitions */
#el {
filter: blur(0px);
transition: filter 0.4s ease;
}
#el:hover {
filter: blur(8px);
}
Filter Animation
@keyframes blur-pulse {
0% { filter: blur(0px); }
50% { filter: blur(6px); }
100% { filter: blur(0px); }
}
#el { animation: blur-pulse 3s ease-in-out infinite; }
Backdrop Filters & Glass Effects
Apply effects to the area behind an element — essential for glassmorphism:
/* Simple blur */
#panel { backdrop-filter: blur(12px); }
/* Combined */
#panel { backdrop-filter: blur(12px) saturate(180%) brightness(80%); }
/* Named materials */
#glass { backdrop-filter: glass; }
#metal { backdrop-filter: metallic; }
#chrome { backdrop-filter: chrome; }
#gold { backdrop-filter: gold; }
#wood { backdrop-filter: wood; }
/* Liquid glass (refracted bevel borders) */
#card {
backdrop-filter: liquid-glass(
blur(18px)
saturate(180%)
brightness(120%)
border(4.0)
tint(rgba(255, 255, 255, 1.0))
);
}
Backdrop Filter Transitions
#panel {
backdrop-filter: blur(4px);
transition: backdrop-filter 0.4s ease;
}
#panel:hover {
backdrop-filter: blur(20px) saturate(180%);
}
Clip Path
Clip elements to geometric shapes:
/* Circle */
#el { clip-path: circle(50% at 50% 50%); }
#el { clip-path: circle(40px at center); }
/* Ellipse */
#el { clip-path: ellipse(50% 35% at 50% 50%); }
/* Inset rectangle */
#el { clip-path: inset(10% 10% 10% 10% round 12px); }
/* Rect / XYWH */
#el { clip-path: rect(10px 90px 90px 10px round 8px); }
#el { clip-path: xywh(10px 10px 80px 80px round 8px); }
/* Polygon */
#hexagon {
clip-path: polygon(50% 0%, 100% 25%, 100% 75%, 50% 100%, 0% 75%, 0% 25%);
}
#star {
clip-path: polygon(
50% 0%, 61% 35%, 98% 35%, 68% 57%, 79% 91%,
50% 70%, 21% 91%, 32% 57%, 2% 35%, 39% 35%
);
}
/* SVG Path */
#el { clip-path: path("M 10 80 C 40 10, 65 10, 95 80 S 150 150, 180 80"); }
Clip Path Animation
@keyframes clip-reveal {
from { clip-path: inset(0% 50% 100% 50%); }
to { clip-path: inset(0% 0% 0% 0%); }
}
#el { animation: clip-reveal 400ms ease-out; }
Mask Image
Apply gradient masks to fade or reveal parts of an element:
/* Linear gradient masks */
#el { mask-image: linear-gradient(to bottom, black, transparent); }
#el { mask-image: linear-gradient(to right, black, transparent); }
#el { mask-image: linear-gradient(135deg, black 0%, transparent 100%); }
/* Radial gradient masks */
#el { mask-image: radial-gradient(circle, black, transparent); }
/* URL-based masks (image texture) */
#el { mask-image: url("mask.png"); }
/* Mask mode */
#el { mask-mode: alpha; } /* default */
#el { mask-mode: luminance; }
Mask Transitions
#reveal {
mask-image: linear-gradient(to bottom, black, transparent);
transition: mask-image 0.6s ease;
}
#reveal:hover {
mask-image: linear-gradient(to bottom, black, black);
}
#radial {
mask-image: radial-gradient(circle, black, transparent);
transition: mask-image 0.5s ease;
}
#radial:hover {
mask-image: radial-gradient(circle, black, black);
}
SVG Styling
Style SVG elements using CSS — including fills, strokes, and path animations.
SVG Properties
svg { stroke: #ffffff; fill: none; stroke-width: 2.5; }
#icon {
fill: #6366f1;
stroke: #ffffff;
stroke-width: 2;
stroke-dasharray: 251;
stroke-dashoffset: 0;
}
SVG Tag-Name Selectors
Target specific SVG sub-element types within a parent:
#my-svg path { stroke: #8b5cf6; stroke-width: 5; }
#my-svg circle { fill: #f3e8ff; stroke: #a78bfa; }
#my-svg rect { fill: #fef3c7; stroke: #f59e0b; }
Supported tags: path, circle, rect, ellipse, line, polygon, polyline, g.
SVG Fill & Stroke Animation
@keyframes color-cycle {
0% { fill: #ef4444; stroke: #dc2626; }
33% { fill: #3b82f6; stroke: #2563eb; }
66% { fill: #10b981; stroke: #059669; }
100% { fill: #ef4444; stroke: #dc2626; }
}
#icon { animation: color-cycle 4s ease-in-out infinite; }
@keyframes glow-stroke {
0% { stroke: #fbbf24; stroke-width: 2; }
50% { stroke: #f43f5e; stroke-width: 5; }
100% { stroke: #fbbf24; stroke-width: 2; }
}
#icon { animation: glow-stroke 2s ease-in-out infinite; }
SVG Hover Transitions
#icon {
fill: #6366f1;
transition: fill 0.3s ease;
}
#icon:hover { fill: #f43f5e; }
#icon2 {
stroke: #64748b;
stroke-width: 2;
transition: stroke 0.3s ease, stroke-width 0.3s ease;
}
#icon2:hover { stroke: #f59e0b; stroke-width: 5; }
Line Drawing Effect
Animate stroke-dashoffset to create a “drawing” effect:
#draw-svg {
stroke-dasharray: 251;
animation: draw 3s ease-in-out infinite alternate;
}
@keyframes draw {
from { stroke-dashoffset: 251; }
to { stroke-dashoffset: 0; }
}
SVG Path Morphing
Animate the d property to morph between shapes. Both shapes must have the same number of path segments:
@keyframes morph {
0% { d: path("M20,20 L80,20 L80,80 L50,80 L20,80 Z"); }
50% { d: path("M50,10 L90,40 L75,85 L25,85 L10,40 Z"); }
100% { d: path("M20,20 L80,20 L80,80 L50,80 L20,80 Z"); }
}
#morph-svg { animation: morph 3s ease-in-out infinite; }
This enables complex effects like hamburger-to-X menu icon animations:
@keyframes hamburger-to-x {
0% { d: path("M20,30 L80,30 M20,50 L80,50 M20,70 L80,70"); }
100% { d: path("M26,26 L74,74 M50,50 L50,50 M26,74 L74,26"); }
}
#menu-icon { animation: hamburger-to-x 1.5s ease-in-out infinite alternate; }
3D Shapes & Lighting
Blinc can render 3D SDF shapes directly via CSS — no mesh files needed.
3D Shape Properties
#sphere {
shape-3d: sphere; /* box | sphere | cylinder | torus | capsule */
depth: 120px;
perspective: 800px;
rotate-x: 30deg;
rotate-y: 45deg;
background: linear-gradient(45deg, #4488ff, #ff4488); /* UV-mapped onto surface */
}
3D Lighting
#lit-shape {
shape-3d: box;
depth: 80px;
perspective: 800px;
light-direction: (0.0, -1.0, 0.5); /* x, y, z */
light-intensity: 1.5;
ambient: 0.3;
specular: 64.0;
translate-z: 20px;
}
3D Boolean Operations (Group Composition)
Combine multiple 3D shapes with boolean operations:
/* Parent must be a group */
#compound { shape-3d: group; perspective: 800px; depth: 80px; }
/* Children contribute shapes */
#base-shape {
shape-3d: box;
depth: 80px;
3d-op: union;
}
#hole {
shape-3d: cylinder;
depth: 120px;
3d-op: subtract;
3d-blend: 30px; /* Smooth blend radius */
}
Available operations: union, subtract, intersect, smooth-union, smooth-subtract, smooth-intersect.
3D Animation
@keyframes spin-y {
from { rotate-y: 0deg; }
to { rotate-y: 360deg; }
}
#rotating-shape {
shape-3d: sphere;
depth: 120px;
perspective: 800px;
animation: spin-y 4s linear infinite;
}
CSS Variables
Define reusable values with custom properties:
:root {
--brand-color: #3b82f6;
--card-radius: 12px;
--hover-opacity: 0.85;
}
#card {
background: var(--brand-color);
border-radius: var(--card-radius);
}
#card:hover {
opacity: var(--hover-opacity);
}
Fallback Values
#el { background: var(--undefined-color, #333); }
Accessing Variables in Rust
#![allow(unused)]
fn main() {
if let Some(value) = stylesheet.get_variable("brand-color") {
println!("Brand color: {}", value);
}
}
Theme Integration
The theme() function references design tokens that adapt to the current app theme:
#card {
background: theme(surface);
border-radius: theme(radius-lg);
box-shadow: theme(shadow-md);
color: theme(text-primary);
border-color: theme(border);
}
#button {
background: theme(primary);
}
#button:hover {
background: theme(primary-hover);
}
Available Theme Tokens
Colors:
| Token | Description |
|---|---|
primary, primary-hover, primary-active | Primary brand colors |
secondary, secondary-hover, secondary-active | Secondary colors |
success, success-bg | Success states |
warning, warning-bg | Warning states |
error, error-bg | Error states |
info, info-bg | Info states |
background, surface, surface-elevated, surface-overlay | Background surfaces |
text-primary, text-secondary, text-tertiary, text-inverse, text-link | Text colors |
border, border-secondary, border-hover, border-focus, border-error | Borders |
Radii: radius-none, radius-sm, radius-default, radius-md, radius-lg, radius-xl, radius-2xl, radius-3xl, radius-full
Shadows: shadow-none, shadow-sm, shadow-default, shadow-md, shadow-lg, shadow-xl
Form Styling
Inputs, checkboxes, radio buttons, and textareas are all styleable via CSS:
Text Input
#my-input {
border-color: #3b82f6;
border-width: 2px;
border-radius: 8px;
color: #ffffff;
caret-color: #60a5fa;
}
#my-input::placeholder {
color: #64748b;
}
#my-input:hover {
border-color: #60a5fa;
}
#my-input:focus {
border-color: #93c5fd;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.3);
}
Checkbox & Radio
#my-checkbox {
accent-color: #3b82f6; /* Checkmark / dot color */
border-color: #475569;
border-radius: 4px;
}
#my-checkbox:hover {
border-color: #3b82f6;
}
#my-checkbox:checked {
background: #3b82f6;
border-color: #3b82f6;
}
Scrollbar
#scrollable {
scrollbar-color: #888 #333; /* thumb-color track-color */
scrollbar-width: thin; /* auto | thin | none */
}
Object Fit & Position
Control how images fill their container:
#image-container img {
object-fit: cover; /* cover | contain | fill | scale-down | none */
object-position: 60% 40%; /* x% y% */
}
Interaction Properties
#overlay { pointer-events: none; } /* auto | none */
#link { cursor: pointer; } /* default | pointer | text | move | not-allowed | grab | ... */
#blend { mix-blend-mode: overlay; } /* normal | multiply | screen | overlay | ... */
Length Units
| Unit | Description | Example |
|---|---|---|
px | Pixels (default) | 12px |
% | Percentage of parent | 50% |
sp | Spacing units (1sp = 4px) | 4sp = 16px |
deg | Degrees (angles) | 45deg |
turn | Full turns (angles) | 0.25turn = 90deg |
rad | Radians (angles) | 1.5708rad ≈ 90deg |
ms | Milliseconds (time) | 300ms |
s | Seconds (time) | 0.3s |
Calc Expressions & Math Functions
Blinc supports CSS calc() with full arithmetic, standard CSS math functions, and shader-inspired extensions. Any property that accepts a numeric value can use calc().
Basic Arithmetic
Standard +, -, *, / with mixed units:
#panel {
width: calc(100% - 40px);
padding: calc(2 * 8px);
margin: calc(100% / 3);
height: calc(50vh - 20px);
}
Units in Calc
All length units from the table above work inside calc():
width: calc(100vw - 300px);
height: calc(50vh - 2em);
rotate: calc(45deg + 0.25turn);
transition-duration: calc(200ms + 0.1s);
Multiply unitless values by a unit literal to produce a dimension:
/* Unitless env var → px */
border-radius: calc(mix(4, 48, 0.5) * 1px);
/* Unitless → degrees */
rotate: calc(env(pointer-x) * 25deg);
CSS Standard Functions
| Function | Syntax | Description |
|---|---|---|
min | min(a, b) | Smaller of two values |
max | max(a, b) | Larger of two values |
clamp | clamp(min, val, max) | Constrain value to range |
#card {
width: clamp(200px, 50%, 600px);
font-size: min(2em, 24px);
padding: max(8px, 2%);
}
Blinc Extension Functions
These shader-inspired functions are available inside calc() for expressive, animation-friendly math:
mix(a, b, t) — Linear Interpolation
Returns a + (b - a) * t. When t = 0 returns a, when t = 1 returns b.
/* Opacity: 30% at t=0, 100% at t=1 */
opacity: calc(mix(0.3, 1.0, env(pointer-inside)));
/* Border-radius between 4px and 48px */
border-radius: calc(mix(4, 48, env(pointer-inside)) * 1px);
smoothstep(edge0, edge1, x) — Hermite Interpolation
Returns a smooth 0→1 S-curve. Result is 0 when x <= edge0, 1 when x >= edge1, and smoothly interpolated between. Uses the Hermite formula: t*t*(3 - 2*t).
When edge0 > edge1, the curve inverts — useful for proximity effects (1 when close, 0 when far):
/* Fade in as pointer approaches (distance 1.8→0 maps to opacity 0→1) */
opacity: calc(smoothstep(1.8, 0.0, env(pointer-distance)));
/* Sharp threshold at 0.5 (smoother than step) */
opacity: calc(smoothstep(0.4, 0.6, env(pointer-inside)));
step(edge, x) — Hard Threshold
Returns 0 if x < edge, 1 otherwise. Binary on/off switch:
/* Fully visible or fully hidden */
opacity: calc(step(0.5, env(pointer-inside)));
remap(val, in_lo, in_hi, out_lo, out_hi) — Range Mapping
Linearly maps val from one range to another:
/* Map pointer-x from [-1, 1] to [10, 50] for border-radius */
border-radius: calc(remap(env(pointer-x), -1, 1, 10, 50) * 1px);
Environment Variables
env() references resolve to per-frame dynamic values. Currently used by the pointer query system:
#card {
pointer-space: self;
rotate-y: calc(env(pointer-x) * 25deg);
opacity: calc(mix(0.3, 1.0, env(pointer-inside)));
}
See the Pointer Query chapter for the full list of env(pointer-*) variables.
Percentage Values
% in calc resolves against the parent dimension (width for horizontal properties, height for vertical):
width: calc(50% - 20px);
margin-left: calc(100% / 6);
Error Handling
The CSS parser is resilient — it collects errors without stopping:
#![allow(unused)]
fn main() {
let result = Stylesheet::parse_with_errors(css);
if result.has_errors() {
result.print_colored_diagnostics(); // Pretty-printed terminal output
result.print_summary();
}
// Valid properties are still parsed!
let style = result.stylesheet.get("card").unwrap();
}
Individual errors include line/column information:
#![allow(unused)]
fn main() {
for error in &result.errors {
println!("Line {}, Col {}: {} (property: {:?})",
error.line, error.column, error.message, error.property);
}
}
Scoped Style Macros
Blinc provides two compile-time macros — css! and style! — for building ElementStyle values directly in Rust. These are ideal for programmatic, scoped styling where you need dynamic values, conditional logic, or simply don’t want a global stylesheet.
Both macros produce the same ElementStyle type and support all the same properties. The difference is syntax:
css! | style! | |
|---|---|---|
| Naming | CSS hyphens (border-radius) | Rust underscores (rounded) |
| Separator | Semicolons | Commas |
| Enum values | Literal keywords (position: absolute;) | Rust expressions (position: StylePosition::Absolute) |
| Best for | Developers from CSS/web | Rust-native code |
Quick Example
#![allow(unused)]
fn main() {
use blinc_layout::prelude::*;
use blinc_core::Color;
// CSS-style syntax
let card = css! {
background: Color::WHITE;
border-radius: 12.0;
box-shadow: lg;
opacity: 0.95;
padding: 24.0;
};
// Equivalent Rust-style syntax
let card = style! {
bg: Color::WHITE,
rounded: 12.0,
shadow_lg,
opacity: 0.95,
p: 24.0,
};
}
Apply a macro style to a Div with .style():
#![allow(unused)]
fn main() {
div().style(css! {
background: Color::rgb(0.1, 0.1, 0.15);
border-radius: 16.0;
padding: 20.0;
})
}
Visual Properties
#![allow(unused)]
fn main() {
// css! macro
let s = css! {
background: Color::BLUE;
border-radius: 8.0;
box-shadow: md; // Presets: sm, md, lg, xl, none
box-shadow: my_shadow; // Or a Shadow value
opacity: 0.8;
clip-path: my_clip_path; // ClipPath value
filter: my_filter; // CssFilter value
mask-image: my_mask; // MaskImage value
mask-mode: blinc_core::MaskMode::Alpha;
mix-blend-mode: blinc_core::BlendMode::Overlay;
};
// style! macro
let s = style! {
bg: Color::BLUE,
rounded: 8.0,
shadow_md, // Presets as bare keywords
opacity: 0.8,
clip_path: my_clip_path,
filter: my_filter,
mask_image: my_mask,
mask_gradient: my_gradient, // Gradient mask shorthand
mask_mode: blinc_core::MaskMode::Alpha,
mix_blend_mode: blinc_core::BlendMode::Overlay,
};
}
Corner Radius Presets (style! only)
#![allow(unused)]
fn main() {
let s = style! {
rounded_sm, // 2.0
rounded_md, // 6.0
rounded_lg, // 8.0
rounded_xl, // 12.0
rounded_2xl, // 16.0
rounded_full, // 9999.0 (pill shape)
rounded_none, // 0.0
};
// Per-corner control
let s = style! {
rounded_corners: (12.0, 12.0, 0.0, 0.0), // top-left, top-right, bottom-right, bottom-left
};
}
Shadow Presets (style! only)
#![allow(unused)]
fn main() {
let s = style! { shadow_sm }; // Small, subtle shadow
let s = style! { shadow_md }; // Medium (default card shadow)
let s = style! { shadow_lg }; // Large, elevated
let s = style! { shadow_xl }; // Extra large, floating
let s = style! { shadow_none }; // Remove shadow
}
Opacity Presets (style! only)
#![allow(unused)]
fn main() {
let s = style! { opaque }; // 1.0
let s = style! { translucent }; // 0.5
let s = style! { transparent }; // 0.0
}
Text Properties
#![allow(unused)]
fn main() {
// css! macro
let s = css! {
color: Color::WHITE;
font-size: 16.0;
font-weight: FontWeight::Bold;
text-decoration: TextDecoration::Underline;
text-decoration-color: Color::RED;
text-decoration-thickness: 2.0;
line-height: 1.5;
text-align: center; // Keywords: left, center, right
letter-spacing: 0.5;
text-shadow: my_shadow;
text-overflow: ellipsis; // Keywords: clip, ellipsis
white-space: nowrap; // Keywords: normal, nowrap, pre
};
// style! macro
let s = style! {
text_color: Color::WHITE,
font_size: 16.0,
font_weight: FontWeight::Bold,
text_decoration: TextDecoration::Underline,
text_decoration_color: Color::RED,
text_decoration_thickness: 2.0,
line_height: 1.5,
text_align: TextAlign::Center,
letter_spacing: 0.5,
text_shadow: my_shadow,
text_overflow: TextOverflow::Ellipsis,
white_space: WhiteSpace::Nowrap,
};
}
Transforms
#![allow(unused)]
fn main() {
// css! macro — function syntax
let s = css! {
transform: scale(1.05);
transform: scale(1.5, 0.8); // Non-uniform
transform: translate(10.0, 20.0);
transform: rotate(45.0);
transform: skewX(15.0);
transform: skewY(10.0);
transform-origin: (50.0, 50.0); // Percentages
};
// css! macro — expression syntax
let s = css! {
transform: my_transform; // A Transform value
};
// style! macro — dedicated properties
let s = style! {
scale: 1.05,
scale_xy: (1.5, 0.8),
translate: (10.0, 20.0),
rotate_deg: 45.0,
skew_x: 15.0,
skew_y: 10.0,
transform_origin: (50.0, 50.0),
};
}
3D Properties
#![allow(unused)]
fn main() {
// css! macro
let s = css! {
rotate-x: 30.0;
rotate-y: 45.0;
perspective: 800.0;
translate-z: 20.0;
shape-3d: "sphere";
depth: 120.0;
light-direction: (0.0, -1.0, 0.5);
light-intensity: 1.5;
ambient: 0.3;
specular: 64.0;
3d-op: "subtract";
3d-blend: 30.0;
};
// style! macro
let s = style! {
rotate_x: 30.0,
rotate_y: 45.0,
perspective: 800.0,
translate_z: 20.0,
shape_3d: "sphere",
depth: 120.0,
light_direction: (0.0, -1.0, 0.5),
light_intensity: 1.5,
ambient: 0.3,
specular: 64.0,
op_3d: "subtract",
blend_3d: 30.0,
};
}
Layout Properties
#![allow(unused)]
fn main() {
// css! macro
let s = css! {
width: 300.0;
height: 200.0;
min-width: 100.0;
max-width: 600.0;
padding: 24.0;
margin: 16.0;
gap: 12.0;
display: flex; // flex | block | none
flex-direction: column; // row | column | row-reverse | column-reverse
flex-wrap: wrap;
flex-grow: 1.0;
flex-shrink: 0.0;
align-items: center; // center | start | end | stretch | baseline
justify-content: space-between; // center | start | end | space-between | space-around | space-evenly
align-self: end; // center | start | end | stretch | baseline
overflow: clip; // clip | hidden | visible | scroll
overflow-x: scroll;
overflow-y: hidden;
};
// style! macro
let s = style! {
w: 300.0,
h: 200.0,
min_w: 100.0,
max_w: 600.0,
p: 24.0,
p_xy: (16.0, 24.0), // Horizontal, vertical
m: 16.0,
m_xy: (8.0, 16.0),
gap: 12.0,
flex_col, // Bare keyword presets
flex_wrap,
flex_grow, // Default = 1.0
flex_grow_value: 2.0, // Specific value
flex_shrink_0, // flex-shrink: 0
flex_shrink: 0.5, // Specific value
items_center,
justify_between,
self_end,
overflow_clip,
overflow_x: StyleOverflow::Scroll,
overflow_y: StyleOverflow::Clip,
display_none,
display_block,
};
}
Position & Inset
#![allow(unused)]
fn main() {
// css! macro — keyword values
let s = css! {
position: absolute; // static | relative | absolute | fixed | sticky
top: 10.0;
right: 0.0;
bottom: 0.0;
left: 10.0;
inset: 0.0; // Sets all four sides
z-index: 5;
visibility: hidden; // visible | hidden
};
// style! macro — expression values
let s = style! {
position: StylePosition::Absolute,
top: 10.0,
inset: 0.0,
z_index: 5,
visibility: StyleVisibility::Hidden,
};
}
Border & Outline
#![allow(unused)]
fn main() {
// css! macro
let s = css! {
border: (2.0, Color::RED); // Shorthand (width, color)
border-width: 2.0;
border-color: Color::RED;
outline: (3.0, Color::BLUE);
outline-width: 3.0;
outline-color: Color::BLUE;
outline-offset: 4.0;
};
// style! macro
let s = style! {
border: (2.0, Color::RED),
border_width: 2.0,
border_color: Color::RED,
outline: (3.0, Color::BLUE),
outline_width: 3.0,
outline_color: Color::BLUE,
outline_offset: 4.0,
};
}
Materials & Layers
#![allow(unused)]
fn main() {
// css! macro — keyword presets
let s = css! {
backdrop-filter: glass; // glass | metallic | chrome | gold | wood
render-layer: foreground; // foreground | background
};
// style! macro — bare keyword presets
let s = style! {
glass, // Also: metallic, chrome, gold, wood
foreground,
};
// Custom material via expression
let s = style! {
material: my_material,
layer: my_layer,
};
}
Animation & Transition
#![allow(unused)]
fn main() {
// css! macro
let s = css! {
animation: my_animation; // CssAnimation value
animation-name: "pulse";
animation-duration: 2000; // milliseconds
animation-delay: 100;
animation-timing-function: AnimationTiming::EaseInOut;
animation-iteration-count: 0; // 0 = infinite
animation-direction: AnimationDirection::Alternate;
animation-fill-mode: AnimationFillMode::Forwards;
transition: my_transition; // CssTransitionSet value
};
// style! macro
let s = style! {
animation: my_animation,
animation_name: "pulse",
animation_duration: 2000,
transition: my_transition,
};
}
SVG Properties
#![allow(unused)]
fn main() {
// css! macro
let s = css! {
fill: Color::RED;
stroke: Color::BLUE;
stroke-width: 2.0;
stroke-dasharray: vec![5.0, 3.0];
stroke-dashoffset: 10.0;
};
// style! macro
let s = style! {
fill: Color::RED,
stroke: Color::BLUE,
stroke_width: 2.0,
stroke_dasharray: vec![5.0, 3.0],
stroke_dashoffset: 10.0,
svg_path_data: "M10,80 L50,20 L90,80",
};
}
Form & Interaction Properties
#![allow(unused)]
fn main() {
// css! macro
let s = css! {
caret-color: Color::rgb(0.4, 0.6, 1.0);
selection-color: Color::BLUE;
placeholder-color: Color::rgba(1.0, 1.0, 1.0, 0.5);
accent-color: Color::GREEN;
scrollbar-color: (Color::rgb(0.5, 0.5, 0.5), Color::rgb(0.2, 0.2, 0.2));
scrollbar-width: thin; // auto | thin | none
pointer-events: none; // auto | none
cursor: CursorStyle::Pointer;
};
// style! macro
let s = style! {
caret_color: Color::rgb(0.4, 0.6, 1.0),
accent_color: Color::GREEN,
scrollbar_color: (Color::rgb(0.5, 0.5, 0.5), Color::rgb(0.2, 0.2, 0.2)),
scrollbar_width: ScrollbarWidth::Thin,
pointer_events_none, // Preset keyword
cursor: CursorStyle::Pointer,
};
}
Image Properties
#![allow(unused)]
fn main() {
// css! macro (0=cover, 1=contain, 2=fill, 3=scale-down, 4=none)
let s = css! {
object-fit: 1;
object-position: (0.5, 0.0); // x, y in 0.0-1.0 range
};
// style! macro
let s = style! {
object_fit: 1,
object_position: (0.5, 0.0),
};
}
Conditional & Dynamic Styling
The macros shine when combined with Rust control flow:
#![allow(unused)]
fn main() {
fn card_style(is_selected: bool, scale: f32) -> ElementStyle {
let mut s = css! {
background: Color::WHITE;
border-radius: 12.0;
padding: 16.0;
};
if is_selected {
s = s.merge(&css! {
border: (2.0, Color::BLUE);
box-shadow: lg;
});
}
// Dynamic transform
s = s.scale(scale);
s
}
}
When to Use Each Approach
| Approach | Best For |
|---|---|
Global CSS (ctx.add_css()) | Shared styles, hover/focus states, animations, selectors |
css! / style! macros | Scoped styles, dynamic values, conditional logic |
Builder API (.w(), .bg()) | One-off overrides, inline on Div builders |
The three approaches compose naturally — CSS provides base styles, macros add scoped overrides, and builder methods fine-tune individual elements.
How It Works
Understanding the CSS pipeline helps debug styling issues.
The Three Styling Approaches
Blinc offers three ways to style elements, in increasing specificity:
- Global stylesheet —
ctx.add_css()+ CSS selectors (recommended for most styling) - Scoped macros —
css!/style!macros for inline ElementStyle - Builder API —
.w(),.h(),.bg()etc. for direct property setting
All three can be combined. CSS provides base styles; builder methods add dynamic values.
CSS Pipeline
CSS Text
↓ ctx.add_css() / Stylesheet::parse_with_errors()
Stylesheet (parsed selectors + ElementStyle rules)
↓ apply_stylesheet_base_styles()
RenderProps (GPU-ready properties on each element)
↓ State changes (hover, focus, checked)
↓ apply_stylesheet_state_styles()
↓ Transition/animation detection & interpolation
↓ apply_animated_layout_props() + compute_layout()
GPU Rendering (SDF shader, image shader, text pipeline)
Frame Loop Order
Each frame, CSS processing happens in this order:
- Tree build — Elements are created,
RenderPropsinitialized - Base styles — Non-state CSS rules applied (complex selectors first, then ID selectors for higher specificity)
- Layout overrides — CSS layout properties (width, padding, gap, etc.) modify the flexbox tree
- Layout computation — Flexbox layout calculated via Taffy
- State styles — Hover/focus/checked states matched, transitions detected
- Animation tick — CSS
@keyframesanimations advance - Transition tick — CSS transitions interpolate toward target
- Layout animation — If animated properties affect layout, re-compute flexbox
- Render — Final RenderProps sent to GPU
Specificity
Rules follow CSS specificity, applied in order:
- Type/class/combinator selectors (lowest)
- ID selectors (highest)
- Later rules override earlier rules at the same specificity level
- State styles (
:hover, etc.) layer on top of base styles
Property Comparison
The same property expressed across all three approaches:
| Global CSS | css! macro | style! macro | Builder API |
|---|---|---|---|
background: #3498db; | background: Color::hex(0x3498db); | bg: Color::hex(0x3498db), | .bg(Color::hex(0x3498db)) |
border-radius: 8px; | border-radius: 8.0; | rounded: 8.0, | .rounded(8.0) |
transform: scale(1.02); | transform: scale(1.02); | scale: 1.02, | .scale(1.02) |
opacity: 0.8; | opacity: 0.8; | opacity: 0.8, | .opacity(0.8) |
width: 200px; | width: 200.0; | w: 200.0, | .w(200.0) |
padding: 16px; | padding: 16.0; | p: 16.0, | .p(16.0) |
gap: 12px; | gap: 12.0; | gap: 12.0, | .gap(12.0) |
flex-direction: column; | flex-direction: column; | flex_col, | .flex_col() |
color: #fff; | color: Color::WHITE; | text_color: Color::WHITE, | .text_color(Color::WHITE) |
font-size: 16px; | font-size: 16.0; | font_size: 16.0, | .font_size(16.0) |
position: absolute; | position: absolute; | position: StylePosition::Absolute, | .position(StylePosition::Absolute) |
fill: red; | fill: Color::RED; | fill: Color::RED, | .fill(Color::RED) |
pointer-events: none; | pointer-events: none; | pointer_events_none, | .pointer_events_none() |
All three approaches can be combined — CSS provides base styles, macros add scoped overrides, and builder methods fine-tune individual elements.
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() -> impl ElementBuilder {
stateful::<ButtonState>()
.on_state(|ctx| {
// Fetch colors inside callback for theme reactivity
let theme = ThemeState::get();
let primary = theme.color(ColorToken::Primary);
let primary_hover = theme.color(ColorToken::PrimaryHover);
let bg = match ctx.state() {
ButtonState::Idle => primary,
ButtonState::Hovered => primary_hover,
_ => primary,
};
div().bg(bg)
})
.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(
message: &str,
variant: ColorToken,
) -> impl ElementBuilder {
let theme = ThemeState::get();
let bg_color = theme.color(variant);
stateful::<ButtonState>()
.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 |ctx| {
let theme = ThemeState::get();
let base = theme.color(variant);
let bg = match ctx.state() {
ButtonState::Hovered => base.with_alpha(0.2),
_ => base.with_alpha(0.15),
};
div().bg(bg)
})
.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("File saved successfully", ColorToken::Success)
notification_toast("Network error occurred", ColorToken::Error)
notification_toast("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() -> impl ElementBuilder {
stateful::<ToggleState>()
.w(100.0)
.h(40.0)
.rounded(8.0)
.flex_center()
.on_state(|ctx| {
let bg = match ctx.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().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() -> impl ElementBuilder {
stateful::<ButtonState>()
.w(200.0)
.h(120.0)
.rounded(12.0)
.on_state(|ctx| {
let (bg, scale) = match ctx.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().bg(bg).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::<S>()for hover/press - Instead of manually tracking hover state, usestateful::<ButtonState>()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::<S>() 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(show_cards: State<bool>) -> impl ElementBuilder {
stateful::<ButtonState>()
.flex_col()
.gap(16.0)
.deps([show_cards.signal_id()])
.on_state(move |ctx| {
let visible = show_cards.get();
let label = if visible { "Hide Cards" } else { "Show Cards" };
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).px(16.0).py(8.0).rounded(8.0)
.child(text(label).color(Color::WHITE))
})
.on_click(move |_| {
show_cards.update(|v| !v);
})
.child(card_list())
}
fn card_list() -> 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, use_shared_state};
#[derive(Clone, Copy, PartialEq, Eq, Hash, Default)]
enum Page {
#[default]
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(current_page: State<u8>) -> impl ElementBuilder {
stateful::<NoState>()
.w_full()
.h_full()
.deps([current_page.signal_id()])
.on_state(move |_ctx| {
// Render different content based on current page signal
let content = match current_page.get() {
0 => div().child(text("Home Page").color(Color::WHITE)),
1 => div().child(text("Settings Page").color(Color::WHITE)),
_ => div().child(text("Profile Page").color(Color::WHITE)),
};
div().child(
motion()
.fade_in(200)
.slide_in(SlideDirection::Right, 200)
.child(content)
)
})
}
// Navigate programmatically using a shared signal
fn nav_button(current_page: State<u8>, target: u8, label: &str) -> impl ElementBuilder {
stateful::<ButtonState>()
.px(16.0)
.py(8.0)
.rounded(8.0)
.on_state(|ctx| {
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 |_| {
current_page.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
Layout Animations (FLIP)
Blinc provides two FLIP-based systems for animating layout changes:
animate_bounds()(Rust API) — Spring-physics-driven, animates position, size, or both. Used by components like accordion and sidebar.- CSS FLIP transitions — CSS-transition-driven, animates position via
transform. Used for sortable lists and grids.
Both follow the same principle: layout runs once to compute final positions, then visual offsets animate elements from where they were to where they are.
What is FLIP?
FLIP stands for First, Last, Invert, Play:
- First — Snapshot every element’s bounds before the layout change.
- Last — Compute the new layout after the change.
- Invert — Apply an offset that moves each element back to where it was.
- Play — Animate the offset from inverted back to zero (the final layout position).
The result: elements glide from their old positions to their new positions, even though the layout change happens instantly.
animate_bounds (Rust API)
The primary way to add layout animations. Call .animate_bounds() on any Div with a VisualAnimationConfig:
#![allow(unused)]
fn main() {
use blinc_layout::visual_animation::VisualAnimationConfig;
div()
.animate_bounds(
VisualAnimationConfig::height()
.with_key("my-panel")
.clip_to_animated()
.gentle(),
)
}
VisualAnimationConfig Presets
| Preset | Animates | Use Case |
|---|---|---|
height() | Height | Accordion panels, collapsible content |
width() | Width | Sidebar expand/collapse |
size() | Width + Height | Containers that resize both axes |
position() | X + Y | Items that shift when siblings change |
all() | Position + Size | Full bounds animation |
Builder Methods
#![allow(unused)]
fn main() {
VisualAnimationConfig::height()
.with_key("unique-key") // Stable identity across rebuilds (required in stateful)
.clip_to_animated() // Clip content to animated bounds during animation
.gentle() // Use gentle spring (SpringConfig::gentle())
// Spring presets
.gentle() // Slow, smooth (stiffness: 120, damping: 14)
.snappy() // Quick, responsive (stiffness: 300, damping: 20)
.stiff() // Fast, minimal overshoot (stiffness: 400, damping: 30)
.wobbly() // Bouncy, playful (stiffness: 180, damping: 12)
.with_spring(SpringConfig { .. }) // Custom spring
// Clipping
.clip_to_animated() // Clip to animated size (hides overflow during collapse)
.clip_to_layout() // Clip to final layout size
.no_clip() // No clipping (content overflows during animation)
// Threshold
.with_threshold(2.0) // Minimum px change to trigger animation (default: 1.0)
}
Stable Keys
Inside stateful containers, elements get new LayoutNodeIds on every rebuild. The .with_key() method provides a stable string identity so the animation system can track an element across rebuilds and smoothly continue from its current visual position.
#![allow(unused)]
fn main() {
// Always use .with_key() inside stateful on_state closures
stateful_with_key::<NoState>("my-container")
.on_state(move |ctx| {
div()
.animate_bounds(
VisualAnimationConfig::height()
.with_key("content-panel") // Survives rebuilds
.clip_to_animated()
.snappy(),
)
})
}
How It Works
Unlike CSS animations that modify render properties, animate_bounds operates at the visual offset level and never touches the layout tree:
- Before rebuild: Snapshot each element’s bounds (keyed by stable key).
- After rebuild: Taffy computes new layout (final positions).
- Detect changes: Compare old bounds to new bounds per key.
- Create spring animations: For each changed element, create
AnimatedValuesprings that start at the delta (old - new) and target 0. - Each frame: Spring values converge toward 0, visual offsets shrink, element glides to final position.
The key principle: Taffy owns layout truth — animations only apply visual offsets on top of layout. This means layout is always correct and animations are purely cosmetic.
Example: Accordion (Height Animation)
An accordion animates the height of collapsible content panels. When a section opens, the content grows from 0 to its natural height. When it closes, it shrinks back.
#![allow(unused)]
fn main() {
use blinc_layout::visual_animation::VisualAnimationConfig;
// The outer accordion container — animates total height as sections open/close
let mut container = div()
.flex_col()
.overflow_clip()
.animate_bounds(
VisualAnimationConfig::height()
.with_key("accordion-container")
.clip_to_animated()
.gentle(),
);
// Each collapsible section — animates its own height
let collapsible = div()
.flex_col()
.overflow_clip()
.animate_bounds(
VisualAnimationConfig::height()
.with_key(&format!("section-{}", key))
.clip_to_animated()
.gentle(),
)
.child(content())
.when(!is_open, |d| d.h(0.0)); // Collapsed: height = 0
// Each item — animates position as siblings expand/collapse
let item = div()
.flex_col()
.animate_bounds(
VisualAnimationConfig::position()
.with_key(&format!("item-{}", key))
.gentle(),
)
.child(trigger)
.child(collapsible);
container = container.child(item);
}
The three animation layers work together:
- Container (
height): Border and background smoothly grow/shrink to fit content. - Collapsible (
height+clip_to_animated): Content area smoothly expands from 0 height, clipped during animation. - Items (
position): Sibling items smoothly slide down/up as the collapsible content grows/shrinks.
Example: Sidebar (Size Animation)
A sidebar animates width when collapsing from expanded (with labels) to collapsed (icons only):
#![allow(unused)]
fn main() {
use blinc_layout::visual_animation::VisualAnimationConfig;
// Items container — animates width + clips during collapse
let items = div()
.flex_col()
.w_fit()
.overflow_clip()
.animate_bounds(
VisualAnimationConfig::all()
.with_key("sidebar-items")
.clip_to_animated()
.snappy(),
);
// Main content area — animates position and size as sidebar shrinks
let content = div()
.flex_1()
.overflow_clip()
.animate_bounds(
VisualAnimationConfig::all()
.with_key("sidebar-content")
.clip_to_animated()
.snappy(),
)
.child(main_content);
// Outer layout
div().flex_row().w_full().h_full()
.child(items)
.child(content)
}
When the sidebar collapses:
- The items container width shrinks (text labels disappear, only icons remain).
clip_to_animated()hides overflowing text during the width transition.- The main content area smoothly expands to fill the freed space.
Clip Behavior
Clipping is essential for collapse/expand animations. Without it, content overflows during the transition:
clip_to_animated()— Clips to the current animated size. Content is hidden as the element shrinks. Use for collapse/expand.clip_to_layout()— Clips to the final layout size. Content is visible during expansion but hidden during collapse.no_clip()— No clipping. Use for position-only animations where size doesn’t change.
CSS FLIP Transitions
For simpler reorder animations (sortable lists, grids), you can use CSS transitions on transform. This system activates automatically when elements with stable IDs move during a subtree rebuild.
Enabling CSS FLIP
Two conditions must be met:
- An element has a stable string ID (
.id("my-item")). - A CSS transition on
transformis defined for that element.
.sort-item {
transition: transform 200ms ease;
}
#![allow(unused)]
fn main() {
let items: Vec<Div> = data.iter().map(|item| {
div()
.id(&format!("item-{}", item.id)) // Stable identity
.class("sort-item") // CSS transition on transform
.child(text(&item.label))
}).collect();
div().children(items)
}
When data order changes and the container rebuilds, each .sort-item slides from its old position to its new one.
How CSS FLIP Works
1. update_flip_bounds() — snapshot positions (keyed by element string ID)
2. Subtree rebuild — recreate children from new data
3. compute_layout() — taffy computes new positions
4. apply_flip_transitions() — compare old vs new, create translate animations
5. tick_flip_animations(dt) — advance by frame delta
6. apply_flip_animation_props()— apply current transform to render props
7. Render
FLIP animations are stored keyed by string element ID (not LayoutNodeId), so they survive subtree rebuilds. Elements with an existing transform (e.g., a dragged item) are automatically excluded.
Customizing the Animation
The FLIP animation inherits transition properties from CSS:
/* Slow, bouncy reorder */
.sort-item {
transition: transform 500ms cubic-bezier(0.34, 1.56, 0.64, 1);
}
/* Fast, linear reorder */
.sort-item {
transition: transform 100ms linear;
}
/* With delay */
.sort-item {
transition: transform 300ms ease 50ms;
}
Example: Sortable Grid
.grid-item {
width: 100px;
height: 100px;
border-radius: 12px;
transition: transform 200ms ease;
}
FLIP computes dx/dy from bounding boxes, so horizontal and vertical movement are both animated automatically.
Choosing Between the Two Systems
| Feature | animate_bounds() | CSS FLIP |
|---|---|---|
| Animation engine | Spring physics | CSS timing functions (ease, linear) |
| Animates position | Yes | Yes |
| Animates size | Yes | No |
| Clip during animation | Yes (clip_to_animated) | No |
| Configuration | Rust builder API | CSS transition property |
| Best for | Accordions, sidebars, panels | Sortable lists, grid reorder |
| Identity tracking | .with_key("...") | .id("...") |
Use animate_bounds() when you need size animation (expand/collapse), content clipping, or spring physics.
Use CSS FLIP when you have a sortable list/grid where items only change position and you want CSS-controlled timing.
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);
// Use stateful::<S>() with .deps() to react to state changes
stateful::<ButtonState>()
.flex_col()
.gap(16.0)
.p(16.0)
.deps([count.signal_id(), step.signal_id()])
.on_state(move |ctx| {
// Read current values inside on_state
let current_count = count.get();
let current_step = step.get();
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),
_ => Color::rgba(0.15, 0.15, 0.2, 1.0),
};
// Return a Div with dynamic content
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())
}
fn increment_button() -> impl ElementBuilder {
stateful::<ButtonState>()
.px(16.0)
.py(8.0)
.rounded(8.0)
.on_state(|ctx| {
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),
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("Increment").color(Color::WHITE))
}
}
Key point: When UI content depends on state values that can change, use stateful::<S>() with .deps() to declare the dependency. The on_state callback re-runs whenever those signals change, and you return a Div with the updated content.
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"))
)
}
Stateful Components
For components needing interactive state or reactive updates:
#![allow(unused)]
fn main() {
use blinc_layout::stateful::stateful;
fn counter_card(count: State<i32>) -> impl ElementBuilder {
stateful::<ButtonState>()
.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 |_ctx| {
let current = count.get();
div().child(text(&format!("Count: {}", current)).color(Color::WHITE))
})
.child(increment_btn(count))
}
fn increment_btn(count: State<i32>) -> impl ElementBuilder {
stateful::<ButtonState>()
.px(16.0)
.py(8.0)
.rounded(8.0)
.on_state(|ctx| {
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 |_| {
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::<S>() instead - it’s more efficient as it doesn’t require continuous redraws.
Stateful Components
Use stateful::<S>() for components with visual states:
#![allow(unused)]
fn main() {
use blinc_layout::stateful::stateful;
fn interactive_card(title: &str) -> impl ElementBuilder {
stateful::<ButtonState>()
.p(16.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),
_ => Color::rgba(0.15, 0.15, 0.2, 1.0),
};
div().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::<S>()for visual states - Hover, press, focus effects should use stateful containers rather 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_examples --example rich_text_demo --features windowed
Code Editor
The code_editor widget provides a full-featured code editing experience with syntax highlighting, line numbers, folding, search, and more.
Read-Only Code Block
Display syntax-highlighted code:
use blinc_layout::prelude::*;
use blinc_layout::syntax::{SyntaxConfig, RustHighlighter};
code(r#"fn main() { println!("Hello"); }"#)
.syntax(SyntaxConfig::new(RustHighlighter::new()))
.line_numbers(true)
.font_size(14.0)
.w_full()
Editable Code Editor
Full editor with Stateful incremental updates:
#![allow(unused)]
fn main() {
let state = code_editor_state("let x = 42;");
code_editor(&state)
.syntax(SyntaxConfig::new(RustHighlighter::new()))
.line_numbers(true)
.font_size(13.0)
.on_change(|new_content| {
println!("Content: {}", new_content);
})
.w_full()
.h(400.0)
}
Features
Editing
- Type, Enter (auto-indent), Backspace, Delete
- Tab / Shift+Tab: indent/dedent selected lines
- Cmd+Backspace/Delete: delete word backward/forward
Navigation
- Arrow keys (with Shift for selection)
- Cmd+Left/Right: word jump
- Smart Home: toggle between first non-whitespace and column 0
- Page Up/Down
- Mouse click cursor positioning
Clipboard & Undo
- Cmd+C/X/V: copy/cut/paste
- Cmd+Z / Cmd+Shift+Z: undo/redo (200-entry history)
- Cmd+A: select all
Visual Features
- Syntax highlighting (Rust, JSON, or custom highlighters)
- Line numbers with gutter
- Current line highlight
- Selection rendering
- Bracket matching
- Indentation guides
- Code folding (click gutter chevrons)
- Minimap (optional scaled-down overview)
Search (Cmd+F)
- VS Code-style search bar overlay
- Case sensitive, whole word, regex toggles
- Match highlighting with navigation (up/down arrows)
- Find and replace with replace all
Syntax Highlighters
Built-in highlighters:
#![allow(unused)]
fn main() {
use blinc_layout::syntax::*;
// Rust
SyntaxConfig::new(RustHighlighter::new())
// JSON
SyntaxConfig::new(JsonHighlighter::new())
// Plain text with custom colors
SyntaxConfig::new(
PlainHighlighter::new()
.text_color(Color::rgba(0.8, 0.9, 0.8, 1.0))
.background(Color::rgba(0.1, 0.12, 0.1, 1.0))
)
}
Custom Highlighter
Implement the SyntaxHighlighter trait:
#![allow(unused)]
fn main() {
struct MyHighlighter;
impl SyntaxHighlighter for MyHighlighter {
fn token_rules(&self) -> &[TokenRule] {
&[
TokenRule::new(r"//.*$", Color::GREEN, false, TokenType::Comment),
TokenRule::new(r#""[^"]*""#, Color::ORANGE, false, TokenType::String),
TokenRule::new(r"\b(fn|let|if|else)\b", Color::PURPLE, true, TokenType::Keyword),
]
}
fn default_color(&self) -> Color { Color::WHITE }
fn background_color(&self) -> Color { Color::rgb(0.1, 0.1, 0.12) }
}
}
Configuration
#![allow(unused)]
fn main() {
code_editor(&state)
.line_numbers(true) // Show line numbers
.font_size(13.0) // Font size in pixels
.line_height(1.5) // Line height multiplier
.padding(16.0) // Content padding
.code_bg(Color::BLACK) // Background color
.text_color(Color::WHITE) // Default text color
.edit(true) // Enable editing (default for code_editor)
.indent_guides(true) // Show vertical indent guides
.code_folding(true) // Enable fold/unfold
.minimap(true) // Show minimap sidebar
}
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.
Virtualized List
The virtual_list widget efficiently renders large datasets by only creating elements for a window of visible items. Items can have variable heights — flexbox layout determines their size.
Basic Usage
#![allow(unused)]
fn main() {
use blinc_layout::widgets::virtual_list::virtual_list;
let items: Vec<String> = (0..10_000)
.map(|i| format!("Item {}", i))
.collect();
virtual_list(items.len(), move |index| {
div()
.w_full()
.p_px(8.0)
.flex_row()
.items_center()
.child(text(&items[index]).size(14.0).color(Color::WHITE))
})
.w_full()
.h(400.0)
.into_div()
}
Variable Height Items
Items don’t need a fixed height. Flexbox handles sizing:
#![allow(unused)]
fn main() {
virtual_list(messages.len(), move |i| {
let msg = &messages[i];
div()
.w_full()
.p_px(12.0)
.flex_col()
.gap_px(4.0)
.child(text(&msg.author).size(12.0).bold().color(Color::WHITE))
.child(text(&msg.body).size(14.0).color(Color::rgba(0.8, 0.8, 0.8, 1.0)))
// Height is determined by content — short messages are small, long ones wrap
})
.w_full()
.h(600.0)
.into_div()
}
Configuration
#![allow(unused)]
fn main() {
virtual_list(count, builder)
.w_full() // Width
.h(400.0) // Viewport height
.bg(Color::BLACK) // Background
.rounded(8.0) // Corner radius
.gap_px(4.0) // Gap between items
.estimated_item_height(48.0) // Hint for scroll spacer (default: 40px)
.window_size(80) // Items to render at once (default: 50)
.into_div()
}
| Option | Default | Description |
|---|---|---|
estimated_item_height | 40.0 | Average item height estimate for scroll spacer calculation |
window_size | 50 | Number of items rendered at once |
How It Works
- The builder creates elements for the first
window_sizeitems - Flexbox layout determines each item’s actual height
- A spacer div below the rendered items estimates the remaining scroll height
- The scroll container provides momentum physics scrolling
- Items use their natural flex-determined height — no fixed constraints
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 apps with many images (galleries, feeds, chat) lazy loading defers decode until the image is actually visible in the viewport. While the real bitmap is loading, Blinc renders a placeholder in its place; once the texture lands in cache, the image fades in over a configurable duration.
How it works
- On each frame, the renderer tests every lazy image’s quad against the current viewport AABB. Off-screen images are skipped — no decode, no GPU upload.
- When an image first intersects the viewport, the loader is triggered and a placeholder is drawn at the image’s final layout rect.
- As soon as the decoded texture appears in the GPU cache, Blinc records
image_load_times[source] = Instant::now()and starts the fade-in.elapsed_ms / fade_duration_msis the alpha multiplier — once it reaches1.0the image is fully opaque and the fade-in flag is cleared. - Placeholder images (type 2) are eagerly preloaded so they’re available the moment the lazy element appears — there’s no flash of empty space waiting on the thumbnail itself.
- While any image is fading in, the runtime keeps requesting redraws so the animation runs smoothly even when nothing else on screen is changing.
Builder API
#![allow(unused)]
fn main() {
use blinc_layout::prelude::*;
use std::time::Duration;
// Basic lazy loading — no placeholder, just defer decode
img("large-photo.jpg")
.lazy()
.w(300.0)
.h(200.0)
// Solid color placeholder
img("photo.jpg")
.lazy()
.placeholder_color(Color::rgba(0.2, 0.2, 0.2, 1.0))
.w(300.0)
.h(200.0)
// Gradient (or any Brush) placeholder
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)
// Thumbnail / blur-hash placeholder — eagerly preloaded
img("large-photo.jpg")
.lazy()
.placeholder_image("thumbnail.jpg")
.fade_in(Duration::from_millis(300))
.w(300.0)
.h(200.0)
// Skeleton shimmer (animated band sweeping left → right)
img("photo.jpg")
.lazy()
.skeleton()
.fade_in(Duration::from_millis(250))
.w(300.0)
.h(200.0)
// Cross-fade off — image pops in instantly when ready
img("photo.jpg")
.lazy()
.no_fade()
.w(300.0)
.h(200.0)
}
Loading strategies
| Strategy | Description |
|---|---|
Eager (default) | Load and decode immediately when the element is created |
Lazy | Defer load until the image’s layout rect intersects the viewport |
Placeholder types
| Placeholder | Description |
|---|---|
None | No placeholder — empty until the bitmap arrives |
Color(color) | Solid color background |
Brush(brush) | Any brush — gradients, glass effects, etc. |
Image(url) | Another image (low-res thumbnail, blur hash). Preloaded on tree build |
Skeleton | Shimmer band animation, no asset required |
CSS overrides
Lazy-loading behavior can be overridden from a stylesheet without touching the builder. This is useful for theming galleries or applying defaults to all images that match a class.
.gallery-item {
loading: lazy;
image-placeholder-type: skeleton;
fade-duration: 250ms;
}
.avatar {
loading: lazy;
image-placeholder-color: rgba(40, 40, 50, 1.0);
fade-duration: 200ms;
}
.hero {
loading: lazy;
image-placeholder-image: "hero-blur.jpg";
fade-duration: 400ms;
}
| Property | Values | Effect |
|---|---|---|
loading | eager | lazy | Switches the loading strategy |
image-placeholder-type | none | color | skeleton | Selects which placeholder to draw |
image-placeholder-color | <color> | Solid color placeholder; implies type: color |
image-placeholder-image | <url> | Thumbnail placeholder; implies type: image |
fade-duration | <time> (200ms, 0.3s) | Fade-in length once the bitmap is ready |
CSS values override builder values. Use the builder for one-off images and CSS for repeated patterns.
Note: Lazy loading currently applies to raster images only. SVGs are vectorized and rasterized on demand, so deferring their decode rarely pays for itself; if you need a deferred SVG, wrap it in a
Statefuland reveal it when needed.
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(icon_path: &str) -> impl ElementBuilder {
stateful::<ButtonState>()
.w(40.0)
.h(40.0)
.rounded(8.0)
.flex_center()
.on_state(|ctx| {
let bg = match ctx.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().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.
Audio & Video
The blinc_media crate provides cross-platform audio/video with royalty-free codecs.
Media widgets in blinc_layout (behind the media feature) provide player UIs.
Audio Playback
#![allow(unused)]
fn main() {
use blinc_media::{AudioPlayer, AudioSource};
let player = AudioPlayer::new();
player.play(AudioSource::file("music.ogg"));
player.set_volume(0.8);
player.pause();
player.seek(30_000); // seek to 30s
player.resume();
println!("Position: {}ms", player.position_ms());
}
Desktop: Vorbis, WAV, FLAC via rodio. Mobile: platform codecs via native bridge.
Video Playback
#![allow(unused)]
fn main() {
use blinc_media::{VideoPlayer, VideoDecoder};
let mut decoder = VideoDecoder::new();
let player = VideoPlayer::new();
// Decode H.264 NAL units → RGBA frames
if let Some(frame) = decoder.decode_nal(h264_packet) {
player.push_frame(frame);
}
player.play();
player.seek(10_000);
}
Desktop: OpenH264 (royalty-free). Mobile: platform decoders via native bridge.
Player Trait
Both players implement the shared Player trait:
#![allow(unused)]
fn main() {
use blinc_media::Player;
fn show_status(p: &dyn Player) {
println!("{} / {} | vol: {}", p.position_ms(), p.duration_ms(), p.volume());
}
}
| Method | Description |
|---|---|
play() / pause() / stop() | Playback controls |
seek(ms) | Seek to position |
position_ms() / duration_ms() | Time tracking |
volume() / set_volume(f32) | Volume (0.0–1.0) |
is_playing() / is_live() | State queries |
Audio Widget
#![allow(unused)]
fn main() {
use std::rc::Rc;
use blinc_layout::widgets::media::audio_player;
let player = Rc::new(AudioPlayer::new());
// Basic controls
audio_player(Rc::clone(&player)).w_full().into_div()
// With waveform
audio_player(Rc::clone(&player))
.waveform_data(&samples)
.w_full()
.into_div()
}
Video Widget
#![allow(unused)]
fn main() {
use std::rc::Rc;
use blinc_layout::widgets::media::video_player;
let player = Rc::new(VideoPlayer::new());
video_player(Rc::clone(&player))
.show_dimensions()
.w_full()
.h(400.0)
.into_div()
}
Waveform
Standalone amplitude visualization:
#![allow(unused)]
fn main() {
use blinc_layout::widgets::media::waveform;
waveform(buckets)
.progress(0.5)
.played_color(Color::BLUE)
.unplayed_color(Color::GRAY)
.w_full().h(60.0)
.into_div()
}
Shared Controls
MediaControls is generic over Player:
#![allow(unused)]
fn main() {
use blinc_layout::widgets::media::MediaControls;
MediaControls::new(player_rc).class("my-controls").into_div()
}
Layout: [ ▶ ] [ 1:23 / 3:45 ] [ ══seek══ ] [ 80% ]
Live streams: [ ▶ ] [ LIVE ] [ ════════════ ]
Camera & Recording
#![allow(unused)]
fn main() {
use blinc_media::rtc::{CameraStream, CameraConfig, AudioRecorder};
let camera = CameraStream::open(CameraConfig::default());
let frame = camera.latest_frame(); // RGBA Frame
let recorder = AudioRecorder::open(Default::default());
let samples = recorder.latest_samples(); // AudioSamples
drop(camera); // stops capture
drop(recorder); // stops recording
}
Frame Utilities
#![allow(unused)]
fn main() {
use blinc_media::{Frame, AudioSamples};
// Video
let small = Frame::from_rgba(data, 640, 480).scale(320, 240);
let gray = small.to_gray();
// Audio
let mono = AudioSamples::from_f32(&pcm, 2, 44100).to_mono();
let resampled = mono.resample(48000);
}
CSS Classes
| Class | Element |
|---|---|
.blinc-audio-player | Audio container |
.blinc-video-player | Video container |
.blinc-audio-waveform | Waveform canvas |
.blinc-media-controls | Controls row |
.blinc-media-play-btn | Play/pause |
.blinc-media-time | Time display |
.blinc-media-live-badge | LIVE indicator |
.blinc-media-seek-track | Seek bar |
.blinc-media-seek-fill | Seek progress |
.blinc-media-volume | Volume |
Licensing
Desktop uses royalty-free codecs only — no ffmpeg, no patent fees:
| Codec | License |
|---|---|
| Vorbis, WAV, FLAC | BSD / Public domain |
| OpenH264 | BSD, Cisco covers patents |
Mobile uses OS-provided codecs (licensing handled by the OS).
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_examples --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.
Overview
blinc_canvas_kit is the layer that turns Blinc’s raw GPU-drawing primitives into an authoring surface. It sits on top of the canvas() element and provides four building blocks:
| API | When to reach for it |
|---|---|
| Sketches | Per-frame immediate-mode drawing with persistent state — particle systems, generative art, live visualisations. |
| Players | Time-based animation sources (Lottie, Rive, custom scene formats) driven by an external t. |
| CanvasKit | Interactive 2D canvases: pan, zoom, hit-testing, pointer / drag / selection callbacks. |
| SceneKit3D | 3D scene authoring: orbit camera, lights, environment maps, mesh draw, gizmos. |
All four share a common principle: the kit owns the per-frame render loop and whatever persistent state the drawing needs, then exposes a small trait (Sketch, Player) or a handle (CanvasKit, SceneKit3D) you feed into a Div tree. State survives UI rebuilds via use_state_keyed, so layout changes, hot reload, and route transitions don’t reset counters, particle systems, camera poses, or asset uploads.
Reach for raw canvas() only when you want a one-shot static render with no animation loop and no persistent state — for example, a chart drawn from a one-time computation.
Import surface
Everything in this chapter lives under the prelude:
#![allow(unused)]
fn main() {
use blinc_canvas_kit::prelude::*;
}
The prelude re-exports Sketch, SketchContext, Painter2D, Player, sketch, CanvasKit, SceneKit3D, OrbitCamera, and the relevant event types. Explicit imports work too — everything is in blinc_canvas_kit or blinc_canvas_kit::sketch.
Animation cadence
Every kit runs its draw callback at the host’s redraw cadence (typically vsync: 60 / 120 Hz) by requesting another frame at the end of each render. There is no opt-out from inside a Sketch — if you want static output, use plain canvas() directly. For deterministic playback (recording frames, scrubbing), drive a Player from outside a sketch and pass synthesised t values.
Sketches
A sketch is a struct that owns its own animation state plus a draw() method called every frame. Implement the Sketch trait on your struct, then mount it into a Div tree with sketch(key, impl).
The Sketch trait
#![allow(unused)]
fn main() {
use blinc_canvas_kit::prelude::*;
use blinc_core::layer::Color;
struct Bouncer {
x: f32,
vx: f32,
}
impl Sketch for Bouncer {
fn draw(&mut self, ctx: &mut SketchContext<'_>, _t: f32, dt: f32) {
self.x += self.vx * dt;
if self.x < 0.0 || self.x + 40.0 > ctx.width {
self.vx = -self.vx;
}
let mut p = ctx.painter();
p.fill(Color::WHITE).no_stroke();
p.rect(self.x, 100.0, 40.0, 40.0);
}
}
}
The trait has two methods:
| Method | Called | Purpose |
|---|---|---|
setup(&mut self, ctx) | Once before the first draw | Asset preload, GPU upload, one-shot layout. Default: no-op. |
draw(&mut self, ctx, t, dt) | Every frame | Mutate state; emit draw calls. t = seconds since the sketch started; dt = seconds since the previous frame. |
Sketches must be Send + 'static — their state lives behind an Arc<Mutex<...>> in Blinc’s persistent state bag.
Mounting: sketch()
#![allow(unused)]
fn main() {
fn build_ui() -> impl ElementBuilder {
div()
.w(600)
.h(400)
.child(sketch("bouncer", Bouncer { x: 0.0, vx: 200.0 }))
}
}
The key identifies the sketch for state persistence. Every sketch("bouncer", ...) with the same key reuses the same persisted state across rebuilds — hot reload, layout changes, route transitions all preserve counters, particle systems, and elapsed time. Pick unique keys per instance.
Wrap the returned Div in a sized container (.w(...), .h(...), .aspect_ratio(...), or a flex parent) to control bounds. The sketch fills its parent.
SketchContext
The per-frame context exposes the canvas size, a frame counter, and three drawing entry points:
#![allow(unused)]
fn main() {
pub struct SketchContext<'a> {
pub width: f32, // Canvas width in layout units
pub height: f32, // Canvas height in layout units
pub frame_count: u64, // Frames drawn since setup()
// ...
}
}
| Method | Returns | Use for |
|---|---|---|
ctx.painter() | Painter2D<'_> | Stateful immediate-mode drawing (Processing-style) |
ctx.draw_context() | &mut dyn DrawContext | Full GPU access: gradients, glass, clips, 3D, images, text |
ctx.play(&mut player, rect, t) | () | Forward to a Player |
painter() and draw_context() each mutably borrow the underlying DrawContext — drop one before calling the other.
Painter2D
The painter holds a current fill, stroke, and transform stack so you don’t repeat those arguments on every primitive call.
Fill & stroke state
#![allow(unused)]
fn main() {
let mut p = ctx.painter();
p.fill(Color::RED).no_stroke(); // Red fill, no outline
p.rect(10.0, 10.0, 100.0, 50.0);
p.stroke(Color::BLACK, 2.0); // Add a 2px black stroke
p.circle(200.0, 200.0, 40.0);
p.no_fill().stroke(Color::BLUE, 1.0);
p.line(0.0, 0.0, 300.0, 300.0);
}
Transform stack
push() / pop() bracket grouped transforms. A single pop() undoes every transform pushed since its matching push():
#![allow(unused)]
fn main() {
p.push();
p.translate(100.0, 100.0);
p.rotate(std::f32::consts::FRAC_PI_4);
p.scale(2.0, 2.0);
p.rect(-10.0, -10.0, 20.0, 20.0); // All three transforms active
p.pop(); // All three transforms undone
}
Calling translate / rotate / scale without a surrounding push() still pushes onto the underlying stack, but pop() can’t undo them. Always use the bracketed pattern for scoped transforms.
When Painter2D’s operations aren’t enough — gradients, glass, clips, 3D, images, text — drop the painter and reach for ctx.draw_context() directly. See Canvas Drawing for the full DrawContext surface.
Players
The Player trait is the contract for time-based animation sources (Lottie, Rive, custom scene files). Implement it once; the same sketch can then drive any player without knowing the format.
The trait
#![allow(unused)]
fn main() {
pub trait Player: Send + 'static {
fn duration(&self) -> Option<f32>;
fn draw_at(&mut self, ctx: &mut SketchContext<'_>, rect: Rect, t: f32);
fn seek(&mut self, _t: f32) {}
fn set_playing(&mut self, _playing: bool) {}
}
}
| Method | Default | Purpose |
|---|---|---|
duration() | required | Total playback duration in seconds. None signals content that plays indefinitely (procedural, live, user-controlled). |
draw_at(ctx, rect, t) | required | Render one frame at time t into rect. Interpolate the scene at t, dispatch draw calls into ctx. |
seek(t) | no-op | Seek internal playback to t. Players that derive every frame from the incoming t don’t need to override. |
set_playing(playing) | no-op | Pause / resume. Paused players should render their frozen pose and ignore t in draw_at. |
Playing a Lottie scene
blinc_lottie::LottiePlayer implements Player. Wrap it in a sketch to run at any size:
#![allow(unused)]
fn main() {
use blinc_app::prelude::*;
use blinc_canvas_kit::prelude::*;
use blinc_core::{Color, Rect};
use blinc_lottie::LottiePlayer;
const LOTTIE_JSON: &str = include_str!("assets/my_animation.json");
struct Loader {
player: LottiePlayer,
}
impl Sketch for Loader {
fn draw(&mut self, ctx: &mut SketchContext<'_>, t: f32, _dt: f32) {
let size = ctx.width.min(ctx.height);
let x = (ctx.width - size) * 0.5;
let y = (ctx.height - size) * 0.5;
ctx.play(&mut self.player, Rect::new(x, y, size, size), t);
}
}
fn build_ui() -> impl ElementBuilder {
let player = LottiePlayer::from_json(LOTTIE_JSON).expect("parse Lottie");
div()
.w_full()
.h_full()
.bg(Color::WHITE)
.child(sketch("lottie", Loader { player }))
}
}
ctx.play(&mut player, rect, t) is a thin forwarder over Player::draw_at — provided so sketches holding a player on self don’t hit borrow-checker friction when draw also reads other self fields.
Lottie specifically supports both plain JSON (from_json) and .lottie archives (from_dotlottie_bytes, requires the dotlottie feature). See the blinc_lottie crate for asset-loading variants.
Writing your own player
Anything that can resolve a pose from a float time implements Player. A minimal example: a player that renders an orbiting dot.
#![allow(unused)]
fn main() {
use blinc_canvas_kit::prelude::*;
use blinc_core::{Color, CornerRadius, Brush, Rect};
struct Orbit;
impl Player for Orbit {
fn duration(&self) -> Option<f32> { None } // plays forever
fn draw_at(&mut self, ctx: &mut SketchContext<'_>, rect: Rect, t: f32) {
let cx = rect.x() + rect.width() * 0.5;
let cy = rect.y() + rect.height() * 0.5;
let r = rect.width().min(rect.height()) * 0.4;
let a = t * std::f32::consts::TAU * 0.5;
let x = cx + r * a.cos() - 8.0;
let y = cy + r * a.sin() - 8.0;
ctx.draw_context().fill_rect(
Rect::new(x, y, 16.0, 16.0),
CornerRadius::uniform(8.0),
Brush::Solid(Color::WHITE),
);
}
}
}
Drop Orbit into any sketch via ctx.play(&mut orbit, rect, t) and it composes with other players, sketches, and UI in the same frame.
CanvasKit: Interactive Canvases
When a sketch needs hit-testing, pointer / keyboard events, pan, or zoom, pair it with CanvasKit. Register hit regions by ID inside draw, install callbacks once on the kit itself, and wire pan/zoom automatically.
Hit regions
#![allow(unused)]
fn main() {
use blinc_canvas_kit::prelude::*;
struct Scene {
kit: CanvasKit,
hovered: Option<String>,
}
impl Sketch for Scene {
fn setup(&mut self, _ctx: &mut SketchContext<'_>) {
self.kit.on_element_click(|ev| {
println!("clicked region: {}", ev.id);
});
// Note: hover callbacks run on pointer enter / leave. Persist the
// hovered id into sketch state to drive per-frame highlight logic.
let hovered = /* reference-counted handle back into sketch state */;
self.kit.on_element_hover(move |ev| {
// update `hovered` here
});
}
fn draw(&mut self, ctx: &mut SketchContext<'_>, _t: f32, _dt: f32) {
// Register hit regions each frame (IDs flow into the callbacks).
self.kit.hit_rect("box-a", Rect::new(50.0, 50.0, 100.0, 100.0));
self.kit.hit_rect("box-b", Rect::new(200.0, 50.0, 100.0, 100.0));
// Draw — pick color based on whatever the hover callback stashed.
}
}
}
Callbacks
All installed on the CanvasKit once (typically in setup or at construction):
| Callback | Fires on |
|---|---|
on_element_click(cb) | Click on a hit region |
on_element_hover(cb) | Enter / leave a hit region |
on_element_drag(cb) | Drag a hit region |
on_element_drag_end(cb) | Drag release |
on_selection_change(cb) | Multi-select / marquee changes |
Each callback receives a CanvasEvent carrying the region id, the content-space pointer position, and the triggering EventContext.
Built-in gestures
CanvasKit wires the following automatically once it’s in a sketch:
- Pan — drag on empty background
- Zoom — scroll wheel (content-space)
- Marquee select — drag from empty background with shift / modifier
- Grid snap — configurable via
kit.snap_rect(rect)/kit.snap_point(p)
Tune sensitivity via the builder methods before mounting:
#![allow(unused)]
fn main() {
let kit = CanvasKit::new("scene")
.with_drag_sensitivity(1.0)
.with_zoom_sensitivity(0.1)
.with_momentum_decay(0.92);
}
Content-space vs screen-space
hit_rect and hit_test operate on content-space coordinates (pre-pan, pre-zoom). The kit transforms pointer events and render bounds into content space before dispatching — you author as if the canvas were infinite and at 1:1 zoom. Use kit.is_visible(rect) to cull content-space rects against the current viewport before drawing expensive primitives.
Full example
See the Canvas Kit Interactive example for a complete walkthrough with pan, zoom, hover feedback, drag, marquee select, and a HUD overlay.
Bundled input routing
To pipe every event the kit receives (pointer, scroll, key) into a single callback — useful for bridging into blinc_input::InputState::record or custom routing — attach .on_canvas_events(|e| ...) to the Div returned by sketch():
#![allow(unused)]
fn main() {
use blinc_canvas_kit::sketch::SketchEvents;
sketch("scene", Scene { kit: CanvasKit::new("scene") })
.on_canvas_events(|ev| input.record(ev))
}
SceneKit3D
SceneKit3D is the 3D counterpart to CanvasKit. It wraps an orbit camera, a set of lights, an environment map, and a mesh list into a single handle you can mount as a Div. Ideal for model viewers, glTF playback, and any 3D content where you don’t want to hand-wire matrix math and event plumbing.
For a lower-level intro (raw MeshData, shaders, materials), see 3D Rendering.
Minimal example
#![allow(unused)]
fn main() {
use blinc_canvas_kit::prelude::*;
use blinc_core::{Material, MeshData};
use std::sync::Arc;
fn build_ui() -> impl ElementBuilder {
let kit = SceneKit3D::new("viewer")
.with_environment(generate_studio_environment(256))
.with_camera(OrbitCamera::default()
.with_distance(5.0)
.with_elevation(0.2));
// Load a mesh — replace with glTF loading in real apps.
let mesh: Arc<MeshData> = load_my_mesh();
kit.add_mesh(mesh);
div()
.w_full()
.h_full()
.child(kit.element_auto())
}
}
element_auto() returns a Div that draws every registered mesh each frame, wires orbit (drag) + zoom (scroll) to the camera, and redraws continuously. element(|ctx, bounds| ...) is the manual equivalent if you want to mix in custom primitives around the scene.
Orbit camera
OrbitCamera is a spherical-coordinate camera around a target point:
#![allow(unused)]
fn main() {
let cam = OrbitCamera::default()
.with_distance(5.0) // Radius from target
.with_azimuth(0.5) // Horizontal angle, radians
.with_elevation(0.3) // Vertical angle, radians
.with_target(Vec3::ZERO) // Look-at point
.with_fov_y(60f32.to_radians());
}
Runtime mutation via the kit:
#![allow(unused)]
fn main() {
kit.update_camera(|cam| {
cam.orbit(dx_rad, dy_rad); // Mouse delta in radians
cam.zoom(1.1); // > 1 zooms out, < 1 zooms in
});
}
kit.camera() snapshots the current camera; kit.camera_signal() gives a signal id you can subscribe to for external UI synced to camera state.
Lights
Lights are added via with_light(...) (builder) or set_lights(vec) (runtime):
#![allow(unused)]
fn main() {
use blinc_core::Light;
let kit = SceneKit3D::new("viewer")
.with_light(Light::directional([0.5, -1.0, 0.3], [1.0, 0.95, 0.9], 1.2))
.with_light(Light::point([2.0, 1.0, 2.0], [0.4, 0.7, 1.0], 5.0));
}
See 3D Rendering for the full Light API (directional, point, spot, with shadow toggles).
Environment maps
Two helpers produce cubemaps ready to feed into with_environment:
#![allow(unused)]
fn main() {
// Procedural studio lighting (gradient sky + soft ground)
let env = generate_studio_environment(256);
// HDRI (Radiance `.hdr` file) → cubemap + irradiance + specular IBL
let hdr_bytes = std::fs::read("studio.hdr")?;
let env = generate_hdri_environment(&hdr_bytes, 512);
let kit = SceneKit3D::new("viewer").with_environment(env);
}
Shortcut: with_hdri(hdr_bytes, face_size) does the read + decode in one call. Both set_environment and set_hdri are available for async loading — spawn a background task, build the EnvironmentData, then apply.
Mesh management
| Method | Purpose |
|---|---|
kit.add(geometry, material) | Add from (Vec<Vertex>, Vec<u32>) + material. Returns a MeshHandle. |
kit.add_mesh(Arc<MeshData>) | Add a pre-built mesh (glTF load, procedural generator). Returns a MeshHandle. |
kit.set_position(handle, pos) | Update mesh world-space translation. |
kit.set_rotation(handle, euler) | Update Euler rotation (radians). |
kit.set_scale(handle, scale) | Update scale. |
kit.set_visible(handle, visible) | Toggle without removing. |
All mutations are &self — the kit is Clone and Send, so background loader threads can push meshes into it as assets resolve. The render closure reads the latest state each frame.
Input wiring
For key-driven camera moves (WASD fly-through, etc.), pair the kit with blinc_input::InputState:
#![allow(unused)]
fn main() {
use blinc_input::InputState;
let input = InputState::new();
let kit = SceneKit3D::new("viewer").with_input(&input);
}
with_input automates the two error-prone pieces users tend to forget:
capture_inputon the outer viewportDivso key/pointer/scroll feed the stateInputState::frame_end()at the end of every paint pass so edge-triggered queries (is_key_just_pressed) stay one-frame-scoped
Gallery
- 3D Mesh Demo — Khronos
DamagedHelmetwith orbit + HDRI - Skeleton animation with glTF — Sketchfab
buster_drone, 39 meshes, 92 nodes, 25s skeletal clip - End-to-end 3D demo — SceneKit3D wired into a Blinc app
Routing & Navigation
The blinc_router crate provides cross-platform routing with path matching, navigation history, guards, page transitions, and deep linking.
Setup
#![allow(unused)]
fn main() {
use blinc_router::{RouterBuilder, Route, PageTransition};
let router = RouterBuilder::new()
.route(Route::new("/").name("home").view(home_page))
.route(Route::new("/users").name("users").view(users_page)
.child(Route::new("/:id").name("user").view(user_detail)))
.route(Route::new("/settings")
.view(settings_page)
.transition(PageTransition::modal()))
.not_found(not_found_page)
.build();
}
Navigation
#![allow(unused)]
fn main() {
// Push (adds to history)
router.push("/users/42");
// Named route with params
router.push_named("user", &[("id", "42")]);
// Replace (no history entry)
router.replace("/login");
// Back / Forward
router.back();
router.forward();
// Check state
router.can_go_back();
router.current_path();
router.params().get("id");
router.query().get("page");
}
Route Outlet
Place router.outlet() where the page content should render:
#![allow(unused)]
fn main() {
fn build_ui(ctx: &WindowedContext) -> impl ElementBuilder {
div().flex_col()
.child(nav_bar(&router))
.child(router.outlet()) // Current route renders here
}
}
use_router() Hook
Inside route views, use_router() returns the active router:
#![allow(unused)]
fn main() {
fn user_detail(ctx: RouteContext) -> Div {
let router = use_router(); // Same as ctx.router
let id = ctx.params.get("id").unwrap_or("?");
div()
.child(text(&format!("User #{}", id)))
.child(
div().on_click(move |_| router.back())
.child(text("Back"))
)
}
}
Nested routers work automatically — use_router() returns whichever router’s outlet() is currently building.
Page Transitions
Per-route transitions using Blinc’s animation system:
#![allow(unused)]
fn main() {
Route::new("/settings")
.view(settings_page)
.transition(PageTransition::slide()) // iOS push style
.transition(PageTransition::fade()) // Crossfade
.transition(PageTransition::modal()) // Slide up/down
.transition(PageTransition::scale()) // Scale in/out
.transition(PageTransition::none()) // Instant
// Custom with spring physics
.transition(PageTransition::slide().with_spring(SpringConfig::bouncy()))
}
Navigation Guards
Protect routes with guards that allow, redirect, or reject:
#![allow(unused)]
fn main() {
use blinc_router::{NavigationGuard, GuardResult};
use std::sync::Arc;
let auth_guard: NavigationGuard = Arc::new(|_from, _to| {
if is_authenticated() {
GuardResult::Allow
} else {
GuardResult::Redirect("/login".into())
}
});
RouterBuilder::new()
.route(Route::new("/dashboard").view(dashboard).guard(auth_guard))
.build();
}
Deep Linking
Deep linking is automatic — just build a router and it works on all platforms.
RouterBuilder::build() auto-registers the deep link handler and back button.
#![allow(unused)]
fn main() {
// That's it — no platform-specific setup needed in Rust
let router = RouterBuilder::new()
.route(Route::new("/users/:id").view(user_page))
.build();
// Deep links to myapp://host/users/42 automatically navigate
}
Platform Configuration
Android — add intent filters in AndroidManifest.xml:
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="myapp" />
</intent-filter>
iOS — add URL types in Info.plist:
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLSchemes</key>
<array><string>myapp</string></array>
</dict>
</array>
Desktop — register a custom URL scheme with the OS:
macOS (Info.plist):
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLSchemes</key>
<array><string>myapp</string></array>
</dict>
</array>
Windows (registry, set up by installer):
HKEY_CLASSES_ROOT\myapp\shell\open\command = "C:\path\to\myapp.exe" "--deep-link=%1"
Linux (.desktop file):
MimeType=x-scheme-handler/myapp
Exec=myapp --deep-link=%u
CLI fallback:
myapp --deep-link=myapp://host/users/42
How It Works
RouterBuilder::build()registers a global deep link handler- Platform runners auto-dispatch incoming URIs to the handler
- The router parses the URI and calls
push(path) - No user code needed beyond building the router
System Back Button
Also automatic — RouterBuilder::build() registers a back button handler.
- Android: system back button navigates back if the router has history
- Desktop:
Key::Backdispatches through the back handler stack - If at the root route, the event propagates (app exits normally)
Route Matching
Express-style path patterns:
| Pattern | Example | Matches |
|---|---|---|
| Static | /about | Exact match |
| Parameter | /users/:id | /users/42 → {id: "42"} |
| Wildcard | /files/*path | /files/a/b/c → {path: "a/b/c"} |
| Nested | parent + child | /users + /:id → /users/42 |
| Query | any path | /search?q=hello → {q: "hello"} |
Named Routes
Look up routes by name for type-safe navigation:
#![allow(unused)]
fn main() {
// Check if a named route exists
router.path_for("user"); // Some("/users/:id")
// Navigate with params
router.push_named("user", &[("id", "42")]); // → /users/42
// Check if a path matches
router.has_route("/users/42"); // true
}
Tab Navigator
Use the tabs() component from blinc_cn with the router’s current path as the active tab:
#![allow(unused)]
fn main() {
use blinc_cn::tabs;
fn app_shell(router: &Router) -> Div {
// Track active tab via router path
let active_tab = ctx.use_state_keyed("tab", || router.current_path());
div().flex_col().w_full().h_full()
// Content area — router outlet
.child(router.outlet().flex_grow())
// Bottom tab bar
.child(
tabs(&active_tab)
.tab("Home", "/", {
let r = router.clone();
move || r.push("/")
})
.tab("Search", "/search", {
let r = router.clone();
move || r.push("/search")
})
.tab("Profile", "/profile", {
let r = router.clone();
move || r.push("/profile")
})
)
}
}
Each tab click calls router.push() which updates the outlet. The tab state stays in sync with the route.
Stack Navigator (Page Stack)
The router maintains a page stack — pages persist in the tree when new pages are pushed on top. Suspended pages have input disabled and are hidden, but their state (scroll position, form values, etc.) is preserved.
#![allow(unused)]
fn main() {
// Renders the page stack — active page visible, suspended pages preserved
router.outlet()
}
When you router.push("/details"):
- The current page becomes Suspended (opacity 0, pointer_events_none)
- The new page is pushed as Active on top
When you router.back():
- The top page is removed from the stack
- The page below becomes Active again (with preserved state)
Page state
#![allow(unused)]
fn main() {
use blinc_router::PageState;
let pages = router.page_stack();
for page in &pages {
match page.state {
PageState::Active => println!("Visible: {}", page.route.path),
PageState::Suspended => println!("Hidden: {}", page.route.path),
}
}
}
Entry/exit animations
Use motion() containers inside route views for animated transitions:
#![allow(unused)]
fn main() {
fn user_detail(ctx: RouteContext) -> Div {
motion()
.slide_in(SlideDirection::Right, 300)
.child(
div().w_full().h_full()
.child(text(&format!("User #{}", ctx.params.get("id").unwrap_or("?"))))
.child(
div().on_click({
let r = ctx.router.clone();
move |_| r.back()
})
.child(text("Back"))
)
)
}
}
Nested Route Stacks
Layout routes can contain their own scoped router for sub-navigation.
use_router() automatically returns the innermost router:
#![allow(unused)]
fn main() {
fn dashboard_layout(ctx: RouteContext) -> Div {
// Create a sub-router for dashboard tabs
let sub_router = RouterBuilder::new()
.route(Route::new("/").view(dashboard_overview))
.route(Route::new("/analytics").view(analytics))
.route(Route::new("/settings").view(settings))
.initial(&ctx.path) // Start at current sub-path
.build();
div().flex_row().w_full().h_full()
.child(dashboard_sidebar(&sub_router))
.child(sub_router.outlet()) // Nested outlet — use_router() returns sub_router here
}
}
Bottom Sheet Navigation
Use sheet() from blinc_cn for modal-like navigation that slides up from the bottom:
#![allow(unused)]
fn main() {
use blinc_cn::sheet;
fn show_details(router: &Router, item_id: &str) {
// Navigate to detail route
router.push(&format!("/items/{}", item_id));
// Or show as a bottom sheet overlay
sheet()
.title("Item Details")
.content(move || {
let router = use_router();
router.outlet() // Render the matched route inside the sheet
})
.show();
}
}
For gesture-dismissable sheets on mobile, the sheet component handles the swipe-down gesture automatically. On dismiss, call router.back():
#![allow(unused)]
fn main() {
sheet()
.on_close({
let r = router.clone();
move || r.back()
})
.content(|| detail_view())
.show();
}
Navigation Patterns Summary
| Pattern | Widget | Router Integration |
|---|---|---|
| Page navigation | router.outlet() | Direct — renders current route |
| Tab bar | blinc_cn::tabs() | Tab clicks call router.push() |
| Stack with animations | stack() + motion() | Wrap route views in motion containers |
| Bottom sheet | blinc_cn::sheet() | Content renders router.outlet(), dismiss calls router.back() |
| Drawer / sidebar | blinc_cn::drawer() | Navigation links call router.push() |
| Back button | Auto-registered | RouterBuilder::build() wires it |
| Deep links | Auto-registered | Platform dispatches to router automatically |
Multi-Window Support
Blinc supports multiple windows on desktop platforms. Each window has its own UI tree, event router, and rendering surface while sharing the GPU device and animation scheduler.
Opening Windows
Use open_window_with() to create a new window with a custom UI builder:
#![allow(unused)]
fn main() {
use blinc_app::windowed::open_window_with;
open_window_with(
WindowConfig::new("Settings")
.size(400, 300)
.center(),
|ctx| {
div()
.w(ctx.width)
.h(ctx.height)
.bg(Color::rgb(0.1, 0.1, 0.15))
.child(text("Settings").size(24.0).color(Color::WHITE))
},
);
}
The builder closure receives &mut WindowedContext with the window’s dimensions and is called each frame when the tree needs rebuilding.
Window Configuration
#![allow(unused)]
fn main() {
WindowConfig::new("My Window")
.size(800, 600) // Initial size
.min_size(400, 300) // Minimum dimensions
.max_size(1920, 1080) // Maximum dimensions
.position(100, 100) // Initial position
.center() // Center on screen
.resizable(true) // Allow resizing
.decorations(false) // Frameless window
.transparent(true) // Transparent background
.always_on_top(true) // Stay above other windows
.modal() // Block input to other windows
}
Modal Windows
Modal windows block input to all other application windows until dismissed:
#![allow(unused)]
fn main() {
open_window_with(
WindowConfig::new("Confirm")
.size(360, 200)
.center()
.resizable(false)
.modal(),
|ctx| {
let close = ctx.close_callback();
div()
.w(ctx.width).h(ctx.height)
.child(text("Are you sure?"))
.child(
div().on_click(move |_| close())
.child(text("OK"))
)
},
);
}
Custom Title Bars
For frameless windows, use .drag_region() to create a draggable title bar, and per-window callbacks for window controls:
#![allow(unused)]
fn main() {
open_window_with(
WindowConfig::new("").size(400, 300).decorations(false),
|ctx| {
let drag = ctx.drag_callback();
let minimize = ctx.minimize_callback();
let maximize = ctx.maximize_callback();
let close = ctx.close_callback();
div()
.w(ctx.width).h(ctx.height)
.flex_col()
// Custom title bar
.child(
div().w_full().h(36.0)
.flex_row().items_center()
// Drag zone (sibling of buttons, not parent)
.child(
div().flex_grow().h_full()
.on_mouse_down(move |_| drag())
.child(text("My App"))
)
// Window controls
.child(div().on_click(move |_| minimize()).child(text("-")))
.child(div().on_click(move |_| maximize()).child(text("+")))
.child(div().on_click(move |_| close()).child(text("x")))
)
// Content
.child(div().flex_grow().child(text("Content")))
},
);
}
Important: Make the drag zone and control buttons siblings (not parent-child) to prevent event bubbling from buttons triggering the drag.
Window State Persistence
Save and restore window position/size across launches:
#![allow(unused)]
fn main() {
use blinc_app::window_state::{WindowStateStore, SavedWindowState};
let store = WindowStateStore::new("my_app");
// Load saved state
let mut config = WindowConfig::default();
if let Some(saved) = store.load("main") {
config = saved.apply_to(config);
}
// Save state on close
store.save("main", &SavedWindowState {
x: 100, y: 200, width: 800, height: 600, maximized: false,
});
}
Per-Window Callbacks
Each WindowedContext provides window-specific action callbacks:
| Method | Description |
|---|---|
ctx.close_callback() | Returns Arc<dyn Fn()> that closes THIS window |
ctx.drag_callback() | Returns Arc<dyn Fn()> that starts OS drag |
ctx.minimize_callback() | Returns Arc<dyn Fn()> that minimizes |
ctx.maximize_callback() | Returns Arc<dyn Fn()> that toggles maximize |
ctx.close() | Close this window directly |
ctx.minimize() | Minimize directly |
ctx.maximize() | Toggle maximize directly |
ctx.open_window(config) | Open a new window |
System Integration
Blinc provides APIs for common desktop system features: file dialogs, system tray, notifications, drag-and-drop, and global keyboard shortcuts.
File Dialogs
Open, save, and folder picker dialogs via the rfd crate:
#![allow(unused)]
fn main() {
use blinc_app::dialog::{open_file, save_file, pick_folder, FileFilter};
// Open a file
if let Some(path) = open_file()
.title("Open Image")
.filter(FileFilter::new("Images").ext("png").ext("jpg"))
.filter(FileFilter::new("All Files").ext("*"))
.pick()
{
println!("Selected: {}", path.display());
}
// Open multiple files
let paths = open_file()
.title("Select Files")
.filter(FileFilter::new("Rust").ext("rs"))
.pick_many();
// Save dialog
if let Some(path) = save_file()
.title("Save As")
.file_name("untitled.txt")
.filter(FileFilter::new("Text").ext("txt"))
.save()
{
println!("Save to: {}", path.display());
}
// Folder picker
if let Some(dir) = pick_folder()
.title("Choose Directory")
.pick()
{
println!("Directory: {}", dir.display());
}
}
System Tray
Create a tray icon with a context menu for background apps:
#![allow(unused)]
fn main() {
use blinc_app::tray::{TrayIconBuilder, TrayMenuItem};
let _tray = TrayIconBuilder::new()
.tooltip("My App v1.0")
.menu(vec![
TrayMenuItem::item("Show Window", || {
// bring window to front
}),
TrayMenuItem::separator(),
TrayMenuItem::submenu("Recent", vec![
TrayMenuItem::item("File 1", || {}),
TrayMenuItem::item("File 2", || {}),
]),
TrayMenuItem::separator(),
TrayMenuItem::item("Quit", || std::process::exit(0)),
])
.build();
// Keep `_tray` alive — dropping it removes the icon
}
You can provide a custom icon:
#![allow(unused)]
fn main() {
let rgba = vec![100, 150, 255, 255].repeat(32 * 32); // 32x32 blue icon
TrayIconBuilder::new()
.icon_rgba(rgba, 32, 32)
.tooltip("My App")
.build();
}
Notifications
Send native desktop notifications:
#![allow(unused)]
fn main() {
use blinc_app::notify::Notification;
Notification::new("Download Complete")
.body("Your file has been saved to ~/Downloads")
.show();
}
Drag and Drop
Window-Level
Register a global file drop handler:
#![allow(unused)]
fn main() {
use blinc_app::dnd::{on_file_drop, DropEvent};
on_file_drop(|event| match event {
DropEvent::Hovered(paths) => println!("Dragging: {:?}", paths),
DropEvent::Dropped(paths) => {
for path in paths {
println!("Dropped: {}", path.display());
}
}
DropEvent::Cancelled => println!("Drag cancelled"),
});
}
Element-Level
Make any element a drop target:
#![allow(unused)]
fn main() {
div()
.w(300.0).h(200.0)
.bg(Color::rgb(0.15, 0.15, 0.2))
.rounded(8.0)
.on_file_drop(|ctx| {
println!("File dropped on this element!");
})
.on_file_drag_over(|ctx| {
// Show visual feedback
})
.on_file_drag_leave(|ctx| {
// Remove visual feedback
})
.child(text("Drop files here"))
}
Global Keyboard Shortcuts
Register system-wide hotkeys that work even when the app isn’t focused:
#![allow(unused)]
fn main() {
use blinc_app::hotkey::GlobalHotkey;
// Active until `_hotkey` is dropped
let _hotkey = GlobalHotkey::new("Ctrl+Shift+P", || {
println!("Global shortcut triggered!");
});
// macOS uses Cmd
let _hotkey2 = GlobalHotkey::new("Cmd+Shift+Space", || {
println!("Quick search!");
});
}
Accelerator format: Ctrl, Shift, Alt, Cmd/Super + key name (e.g., A, F1, Space, Enter).
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.
Pointer Query
The pointer query system exposes continuous cursor position, velocity, distance, and angle as CSS environment variables on any element. This lets you build pointer-reactive effects — 3D tilt, hover reveals, distance-based glow, dynamic corners — entirely in CSS, with no Rust event handlers.
How It Works
- Set
pointer-spaceon an element in CSS to enable tracking. - Each frame, the system computes the pointer’s normalized position relative to that element.
- Results are exposed as
env()variables usable in anycalc()expression. - Any numerical CSS property can read these values: opacity, border-radius, rotate, border-width, perspective transforms, and more.
#card {
pointer-space: self;
pointer-origin: center;
pointer-range: -1.0 1.0;
pointer-smoothing: 0.08;
/* 3D tilt follows cursor */
perspective: 800px;
rotate-y: calc(env(pointer-x) * env(pointer-inside) * 25deg);
rotate-x: calc(env(pointer-y) * env(pointer-inside) * -25deg);
}
#![allow(unused)]
fn main() {
div()
.id("card")
.class("my-card")
.w(300.0)
.h(200.0)
.child(text("Hover me"))
}
No event handlers, no state management — the CSS drives everything.
CSS Properties
These properties configure pointer tracking on an element. Setting pointer-space activates the system for that element.
pointer-space
The coordinate space for pointer position computation.
| Value | Description |
|---|---|
self | Position relative to the element’s own bounds (default) |
parent | Position relative to the parent element |
viewport | Position relative to the viewport |
#card { pointer-space: self; }
pointer-origin
The origin point for coordinate normalization.
| Value | Description |
|---|---|
center | (0,0) at element center, extends symmetrically (default) |
top-left | (0,0) at top-left corner |
bottom-left | (0,0) at bottom-left, Y-up (shader coordinates) |
#card { pointer-origin: center; }
pointer-range
The output range for normalized coordinates. Takes two floats: min and max.
/* Default: symmetric -1 to 1 (good for center origin) */
#card { pointer-range: -1.0 1.0; }
/* 0 to 1 (good for top-left origin) */
#card { pointer-range: 0.0 1.0; }
With center origin and -1.0 1.0 range:
- Cursor at element center:
pointer-x = 0,pointer-y = 0 - Cursor at left edge:
pointer-x = -1 - Cursor at right edge:
pointer-x = 1
pointer-smoothing
Exponential smoothing time constant in seconds. Smooths position, velocity, and the pointer-inside flag for gradual transitions.
/* No smoothing — instant tracking */
#card { pointer-smoothing: 0; }
/* Subtle lag — responsive but smooth */
#card { pointer-smoothing: 0.08; }
/* Heavy smoothing — slow, floaty feel */
#card { pointer-smoothing: 0.2; }
When the cursor leaves the element, smoothed values decay toward the origin (0,0) instead of snapping. This creates a natural fade-out effect.
Environment Variables
Once pointer-space is set on an element, these env() variables resolve inside any calc() expression on that element:
| Variable | Type | Description |
|---|---|---|
env(pointer-x) | float | Normalized X position in configured range |
env(pointer-y) | float | Normalized Y position in configured range |
env(pointer-vx) | float | X velocity (normalized units/second) |
env(pointer-vy) | float | Y velocity (normalized units/second) |
env(pointer-speed) | float | Total speed: sqrt(vx² + vy²) |
env(pointer-distance) | float | Distance from origin (normalized units) |
env(pointer-angle) | float | Angle from origin (radians, 0 = right, pi/2 = up) |
env(pointer-inside) | 0.0/1.0 | 1.0 if cursor is inside element, 0.0 otherwise (smoothed) |
env(pointer-active) | 0.0/1.0 | 1.0 if mouse button is pressed while over element |
env(pointer-pressure) | float | Touch/click pressure (0.0-1.0). Mouse: binary 0/1. Touch: hardware pressure (smoothed) |
env(pointer-touch-count) | float | Number of active touch points (0 for mouse input) |
env(pointer-hover-duration) | float | Seconds since cursor entered (0 if outside) |
Using pointer-inside as a Gate
Multiply by env(pointer-inside) to make effects only appear on hover:
/* Rotation ONLY when hovered */
rotate: calc(env(pointer-x) * env(pointer-inside) * 5deg);
/* Opacity: 0.3 normally, 1.0 on hover */
opacity: calc(mix(0.3, 1.0, env(pointer-inside)));
Because pointer-inside is smoothed, the transition in/out is gradual when pointer-smoothing is set.
Calc Functions
These functions work inside calc() and are especially useful with pointer variables:
| Function | Signature | Description |
|---|---|---|
mix | mix(a, b, t) | Linear interpolation: a + (b - a) * t |
smoothstep | smoothstep(edge0, edge1, x) | Hermite interpolation (smooth 0-1 curve) |
step | step(edge, x) | 0 if x < edge, 1 otherwise |
clamp | clamp(min, val, max) | Clamp value to range |
remap | remap(val, in_lo, in_hi, out_lo, out_hi) | Remap from one range to another |
mix — Linear Interpolation
/* Opacity: 30% when far, 100% when hovering */
opacity: calc(mix(0.3, 1.0, env(pointer-inside)));
/* Border-radius: 4px far, 48px near */
border-radius: calc(mix(4, 48, smoothstep(1.4, 0.0, env(pointer-distance))) * 1px);
smoothstep — Smooth Transitions
Creates an S-curve between two edge values. When edge0 > edge1, the curve is inverted (1 at close range, 0 at far range).
/* Opacity fades in as pointer approaches (inverted smoothstep) */
opacity: calc(smoothstep(1.8, 0.0, env(pointer-distance)));
Units in calc()
Pointer env variables are unitless floats. To produce a CSS value with units, multiply by a unit literal:
/* 1px unit applied after the math */
border-radius: calc(mix(4, 48, env(pointer-inside)) * 1px);
border-width: calc(mix(0, 4, env(pointer-inside)) * 1px);
/* Degrees for rotation */
rotate-y: calc(env(pointer-x) * 25deg);
Examples
3D Tilt Card
Perspective rotate-x/y follow the cursor for a true 3D card effect.
#tilt-card {
pointer-space: self;
pointer-origin: center;
pointer-range: -1.0 1.0;
pointer-smoothing: 0.08;
border-radius: 16px;
background: #1e2438;
perspective: 800px;
rotate-y: calc(env(pointer-x) * env(pointer-inside) * 25deg);
rotate-x: calc(env(pointer-y) * env(pointer-inside) * -25deg);
}
Hover Reveal
Element fades from dim to full brightness on hover.
#reveal-card {
pointer-space: self;
pointer-smoothing: 0.12;
background: #2a1a3e;
opacity: calc(mix(0.3, 1.0, env(pointer-inside)));
}
Distance-Based Effects
Opacity, corners, or borders that respond to how close the cursor is to the element’s center.
#distance-card {
pointer-space: self;
pointer-origin: center;
pointer-range: -1.0 1.0;
pointer-smoothing: 0.06;
/* Opacity increases as pointer approaches center */
opacity: calc(smoothstep(1.8, 0.0, env(pointer-distance)));
}
#corners-card {
pointer-space: self;
pointer-origin: center;
pointer-range: -1.0 1.0;
pointer-smoothing: 0.08;
/* Corners round as pointer approaches */
border-radius: calc(mix(4, 48, smoothstep(1.4, 0.0, env(pointer-distance))) * 1px);
}
Border Glow
Border grows and appears as the cursor approaches.
#border-card {
pointer-space: self;
pointer-origin: center;
pointer-range: -1.0 1.0;
pointer-smoothing: 0.06;
border-radius: 16px;
border-color: #4488cc;
border-width: calc(mix(0, 4, smoothstep(1.4, 0.0, env(pointer-distance))) * 1px);
opacity: calc(mix(0.3, 1.0, smoothstep(1.8, 0.0, env(pointer-distance))));
}
Subtle Rotation
Card rotates gently following cursor x-position.
#rotate-card {
pointer-space: self;
pointer-origin: center;
pointer-range: -1.0 1.0;
pointer-smoothing: 0.1;
rotate: calc(env(pointer-x) * env(pointer-inside) * 5deg);
opacity: calc(mix(0.5, 1.0, env(pointer-inside)));
}
Pressure Response
Scale and opacity respond to touch pressure or click state. On desktop, mouse clicks produce a binary 0→1 pressure that smooths naturally via pointer-smoothing. On mobile devices with 3D Touch or pressure-sensitive screens, the response is continuous.
#pressure-card {
pointer-space: self;
pointer-smoothing: 0.06;
/* Scale up slightly when pressed, proportional to pressure */
scale: calc(1.0 + env(pointer-pressure) * 0.1);
/* Full opacity when pressed hard */
opacity: calc(mix(0.4, 1.0, env(pointer-pressure)));
}
Combined Effects
Multiple properties respond simultaneously for rich interactive cards.
#combo-card {
pointer-space: self;
pointer-origin: center;
pointer-range: -1.0 1.0;
pointer-smoothing: 0.08;
border-radius: calc(mix(8, 40, smoothstep(1.4, 0.0, env(pointer-distance))) * 1px);
border-width: calc(mix(0, 3, smoothstep(1.2, 0.0, env(pointer-distance))) * 1px);
border-color: #cc66aa;
opacity: calc(smoothstep(1.6, 0.0, env(pointer-distance)));
rotate: calc(env(pointer-x) * env(pointer-inside) * 3deg);
}
How It Works Internally
-
Registration: When the CSS parser encounters
pointer-spaceon an element, it stores aPointerSpaceConfigon theElementStyle. During stylesheet application, elements with this config are registered inPointerQueryState. -
Per-frame update: Each frame,
PointerQueryState::update()runs for all tracked elements. It uses the event router’s hit test results to determine hover state and element bounds, then computes normalized coordinates, velocity, distance, and angle. -
Env resolution: When a
calc()expression containingenv(pointer-*)is evaluated (for opacity, border-radius, rotate, etc.), it resolves against the element’sElementPointerState. -
Continuous redraw: While any pointer-tracked element is hovered (or smoothing is active), the system requests redraws to keep values updating.
State is keyed by element string ID (not LayoutNodeId), so it persists across tree rebuilds. Smoothed values carry over seamlessly.
Tips
- Always use
pointer-smoothingfor visual properties — even a small value like0.06eliminates jitter and creates a polished feel. - Gate with
pointer-insideto prevent effects from firing when the cursor is far away. Multiply:env(pointer-x) * env(pointer-inside). - Use
smoothstepfor distance effects — rawpointer-distancedrops off linearly, butsmoothstepcreates a natural proximity gradient. - Combine freely — all env variables are independent. Mix position-based rotation with distance-based opacity and hover-gated borders in the same element.
- Performance: Only elements with
pointer-spaceset are tracked. No per-frame cost for elements that don’t opt in.
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::<S>() for hover, press, and focus effects:
#![allow(unused)]
fn main() {
use blinc_layout::stateful::stateful;
fn hover_button() -> impl ElementBuilder {
stateful::<ButtonState>()
.px(16.0)
.py(8.0)
.rounded(8.0)
.on_state(|ctx| {
let bg = match ctx.state() {
ButtonState::Idle => Color::RED,
ButtonState::Hovered => Color::BLUE,
_ => Color::RED,
};
div().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::<S>() 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 |
Flow Shaders
Flow shaders are a DAG-based (directed acyclic graph) real-time shader compute system that compiles to WGSL. They support fragment, compute, vertex, and material targets — powering 2D effects, GPU simulation, and 3D mesh rendering from a single declarative language. Flows can be defined in CSS stylesheets or directly in Rust using the flow! macro.
Quick Start
The fastest way to add a flow shader to an element:
#![allow(unused)]
fn main() {
use blinc_layout::flow;
let ripple = flow!(ripple, fragment, {
input uv: builtin(uv);
input time: builtin(time);
node d = distance(uv, vec2(0.5, 0.5));
node wave = sin(d * 20.0 - time * 4.0) * 0.5 + 0.5;
output color = vec4(wave, wave, wave, 1.0);
});
div().flow(ripple).w(400.0).h(400.0)
}
The flow! macro produces a FlowGraph using Rust identifiers and primitives. Pass it directly to any element via .flow().
Anatomy of a Flow Shader
Every flow shader has a name, a target, and a body of declarations:
@flow <name> {
target: fragment | compute | vertex | material;
input <name>: builtin(<variable>); // Input declarations
step <name>: <step-type> { ... }; // Semantic steps (high-level)
node <name> = <expression>; // Raw computation nodes
chain <name>: <step> | <step> | ...; // Piped step chains
use <flow-name>; // Compose other flows
output <target> = <expression>; // Output declarations
}
Declarations can appear in any order, but each node can only reference inputs and earlier nodes (the graph must be acyclic).
Targets
| Target | Use Case | Output |
|---|---|---|
fragment | 2D visual effects on UI elements | color (vec4) |
compute | GPU simulation, data processing | Named buffer writes |
vertex | 3D mesh vertex transformation | position (vec4 clip-space) |
material | 3D mesh surface/PBR shading | albedo, metallic, roughness, etc. |
Builtin Variables
Fragment / Compute Builtins
| Variable | Type | Description |
|---|---|---|
uv | vec2 | Normalized element coordinates (0,0 = top-left, 1,1 = bottom-right) |
time | float | Elapsed time in seconds (monotonic) |
resolution | vec2 | Element size in physical pixels |
pointer | vec2 | Cursor position relative to element (0-1 range) |
sdf | float | Signed distance field value at the current fragment |
frame_index | float | Current frame number |
Vertex Target Builtins
| Variable | Type | Description |
|---|---|---|
vertex_position / position | vec3 | Vertex position in model space |
vertex_normal / normal | vec3 | Vertex normal in model space |
vertex_tangent / tangent | vec4 | Tangent (xyz = dir, w = handedness) |
vertex_color | vec4 | Per-vertex color |
joints | vec4<u32> | Joint indices for skeletal animation |
weights | vec4 | Joint weights |
vertex_index | float | Vertex/instance index |
model_matrix / model | mat4 | Model-to-world transform |
view_proj / view_projection | mat4 | View-projection matrix |
Material Target Builtins
| Variable | Type | Description |
|---|---|---|
world_position / world_pos | vec3 | Interpolated world-space position |
world_normal | vec3 | Interpolated world-space normal |
world_tangent | vec3 | Interpolated world-space tangent |
tangent_handedness | float | Tangent handedness (±1) |
camera_position / camera_pos | vec3 | Camera position in world space |
light_direction / light_dir | vec3 | Directional light direction |
light_intensity | float | Light intensity |
uv | vec2 | Texture coordinates (also available in material) |
time | float | Frame time (also available in material) |
Expressions
Flow expressions support standard arithmetic, vector constructors, function calls, and swizzle access:
node a = sin(uv.x * 10.0 + time);
node b = vec4(a, a * 0.5, 1.0 - a, 1.0);
node c = mix(b, vec4(1.0, 0.0, 0.0, 1.0), 0.5);
node d = c.rgb;
Operators
| Operator | Example |
|---|---|
+, -, *, / | a * 2.0 + b |
Unary - | -a |
| Swizzle | v.xy, v.rgb, v.x |
Functions Reference
Math (scalar)
| Function | Signature | Description |
|---|---|---|
sin, cos, tan | f32 -> f32 | Trigonometric |
abs, floor, ceil, fract | f32 -> f32 | Rounding / absolute |
sqrt, exp, log, sign | f32 -> f32 | Algebraic |
pow | (f32, f32) -> f32 | Power |
atan2 | (f32, f32) -> f32 | Arc tangent |
mod | (f32, f32) -> f32 | Modulus |
min, max | (f32, f32) -> f32 | Comparative |
clamp | (f32, f32, f32) -> f32 | Clamp to range |
mix | (f32, f32, f32) -> f32 | Linear interpolation |
smoothstep | (f32, f32, f32) -> f32 | Smooth Hermite |
step | (f32, f32) -> f32 | Step function |
Vector
| Function | Description |
|---|---|
length(v) | Vector magnitude |
distance(a, b) | Distance between two points |
dot(a, b) | Dot product |
cross(a, b) | Cross product (vec3) |
normalize(v) | Unit vector |
reflect(v, n) | Reflection |
Noise
| Function | Signature | Description |
|---|---|---|
fbm(p, octaves) | (vec2, i32) -> f32 | Fractal Brownian motion |
fbm_ex(p, octaves, persistence) | (vec2, i32, f32) -> f32 | FBM with custom persistence |
worley(p) | vec2 -> f32 | Worley/cellular noise |
worley_grad(p) | vec2 -> vec3 | Worley with analytic gradient (x=dist, y=gx, z=gy) |
checkerboard(p, scale) | (vec2, f32) -> f32 | Checkerboard pattern |
SDF Primitives
| Function | Description |
|---|---|
sdf_box(p, half_size) | Box SDF |
sdf_circle(p, radius) | Circle SDF |
sdf_ellipse(p, radii) | Ellipse SDF |
sdf_round_rect(p, half_size, radius) | Rounded rectangle SDF |
SDF Combinators
| Function | Description |
|---|---|
sdf_union(a, b) | Union of two SDFs |
sdf_intersect(a, b) | Intersection |
sdf_subtract(a, b) | Subtraction |
sdf_smooth_union(a, b, k) | Smooth union with radius k |
sdf_smooth_intersect(a, b, k) | Smooth intersection |
sdf_smooth_subtract(a, b, k) | Smooth subtraction |
Lighting
| Function | Description |
|---|---|
phong(normal, light_dir, view_dir, shininess) | Phong shading |
blinn_phong(normal, light_dir, view_dir, shininess) | Blinn-Phong shading |
Matrix (for vertex/material targets)
| Function | Signature | Description |
|---|---|---|
mat4_mul_vec4(m, v) | (mat4, vec4) -> vec4 | Matrix-vector multiply |
mat4_mul(a, b) | (mat4, mat4) -> mat4 | Matrix-matrix multiply |
mat4_inverse(m) / inverse(m) | mat4 -> mat4 | Matrix inverse |
mat4_transpose(m) / transpose(m) | mat4 -> mat4 | Matrix transpose |
transform_normal(model, n) | (mat4, vec3) -> vec3 | Transform normal by model matrix (3x3 extract) |
translation_matrix(v) | vec3 -> mat4 | Translation matrix from offset |
rotation_matrix(axis, angle) | (vec3, float) -> mat4 | Rotation from axis + angle |
scale_matrix(v) | vec3 -> mat4 | Scale matrix from factors |
perspective(fov, aspect, near, far) | (f, f, f, f) -> mat4 | Perspective projection |
look_at(eye, target, up) | (vec3, vec3, vec3) -> mat4 | View matrix |
sample_texture(id, uv) | (float, vec2) -> vec4 | Sample a bound texture at UV |
Scene
| Function | Description |
|---|---|
sample_scene(uv) | Sample the background behind this element (for refraction/glass effects) |
Semantic Steps
Steps are high-level operations that expand to multiple nodes automatically. They provide a more declarative way to build shader effects.
Pattern Steps
Generate procedural textures. Output type: float (scalar field).
| Step Type | Key Parameters | Description |
|---|---|---|
pattern_noise | scale, detail, animation | FBM noise pattern |
pattern_worley | scale, threshold, edge, mask, gradient | Worley cellular pattern with analytic gradient |
pattern_ripple | center, density, speed | Concentric ripple rings |
pattern_waves | direction, frequency, speed | Directional sine waves |
pattern_grid | scale, line_width | Grid lines |
pattern_gradient | direction, start, end | Linear gradient (output: vec4) |
pattern_plasma | scale, speed | Plasma texture (output: vec4) |
Effect Steps
Post-processing effects that modify appearance.
| Step Type | Key Parameters | Description |
|---|---|---|
effect_refract | source, strength | Lens refraction via Worley gradient |
effect_frost | source, strength, detail | Frosted glass UV jitter |
effect_specular | source, intensity, power | Specular highlight scattering |
effect_fog | density, source | Fog/haze composite |
effect_light | source, direction, intensity, power | Directional highlights from normals |
Transform Steps
Spatial coordinate transformations. Output type: vec2 (UV coordinate) or float.
| Step Type | Key Parameters | Description |
|---|---|---|
transform_wet | aspect, scroll_speed, offset | Aspect-corrected gravity scroll (for rain/drip effects) |
transform_warp | source, amount | Warp UV by a noise field |
transform_rotate | angle | Rotate UV coordinates |
transform_scale | factor | Scale UV coordinates |
transform_tile | count | Tile/repeat UV |
transform_mirror | axis | Mirror UV |
transform_polar | center | Cartesian to polar coordinates |
Color Steps
Map scalar values to colors. Output type: vec4.
| Step Type | Key Parameters | Description |
|---|---|---|
color_ramp | source, stops, opacity | Map scalar to color gradient |
color_shift | source, hue | Hue shift |
color_tint | source, color | Color tinting |
color_invert | source | Color inversion |
Composition Steps
Combine two sources. Output type: vec4.
| Step Type | Key Parameters | Description |
|---|---|---|
compose_blend | a, b, mode | Blend two layers (screen, multiply, overlay, etc.) |
compose_mask | source, mask | Alpha mask one input by another |
compose_layer | base, overlay, opacity | Stack with opacity |
Adjust Steps
Value curve shaping. Output type: float.
| Step Type | Key Parameters | Description |
|---|---|---|
adjust_falloff | radius, center | Distance-based fade |
adjust_remap | source, in_min, in_max, out_min, out_max | Remap value range |
adjust_threshold | source, value | Hard threshold |
adjust_ease | source, curve | Apply easing curve |
adjust_clamp | source, min, max | Clamp value range |
Chains
Chains pipe the output of one step into the next, creating a processing pipeline:
chain effect:
pattern_ripple(center: vec2(0.5, 0.5), density: 25.0)
| adjust_falloff(radius: 0.5)
;
Each link in the chain implicitly receives the previous link’s output as its source parameter.
Flow Composition with use
Flows can import nodes from other flows using use:
@flow base_noise {
target: fragment;
input uv: builtin(uv);
node n = fbm(uv * 4.0, 6);
output color = vec4(n, n, n, 1.0);
}
@flow enhanced {
target: fragment;
use base_noise;
node bright = smoothstep(0.3, 0.7, n);
output color = vec4(bright, bright * 0.5, 0.1, 1.0);
}
The use directive imports all nodes from the referenced flow into the current graph.
Scene Sampling
For glass, refraction, or frosted effects, use sample_scene() to read the rendered background behind the element:
#![allow(unused)]
fn main() {
let glass = flow!(glass, fragment, {
input uv: builtin(uv);
input time: builtin(time);
node offset = fbm(uv * 8.0 + vec2(time * 0.1, 0.0), 3) * 0.02;
node scene = sample_scene(uv + vec2(offset, offset));
output color = scene;
});
}
The scene texture is automatically captured before flow rendering begins. Elements using sample_scene() see everything rendered behind them.
Applying Flow Shaders
There are three ways to apply flow shaders to elements:
1. flow! Macro (Recommended)
Define the shader in Rust and pass it directly to the element:
#![allow(unused)]
fn main() {
use blinc_layout::flow;
let shader = flow!(my_effect, fragment, {
input uv: builtin(uv);
input time: builtin(time);
node wave = sin(uv.x * 10.0 + time) * 0.5 + 0.5;
output color = vec4(wave, 0.2, 0.5, 1.0);
});
div().flow(shader).w(300.0).h(300.0)
}
The FlowGraph carries its own name and is auto-persisted by the GPU pipeline cache.
2. CSS Stylesheet
Define flows in CSS and reference them by name:
#![allow(unused)]
fn main() {
ctx.add_css(r#"
@flow terrain {
target: fragment;
input uv: builtin(uv);
step noise: pattern-noise { scale: 4.0; detail: 6; };
output color = vec4(noise, noise, noise, 1.0);
}
#my-element {
flow: terrain;
border-radius: 16px;
}
"#);
div().id("my-element").w(300.0).h(300.0)
}
3. Style Macros
Reference CSS-defined flows from css! or style! macros:
#![allow(unused)]
fn main() {
let style = css! {
flow: "terrain";
border-radius: 16px;
};
// Or with style! macro:
let style = style! {
flow: "terrain",
corner_radius: 16.0,
};
}
4. Name Reference
Reference a previously-defined flow by name string:
#![allow(unused)]
fn main() {
div().flow("terrain").w(300.0).h(300.0)
}
Complete Example
Here’s the wet glass demo that creates a realistic rain-on-glass effect using semantic steps:
#![allow(unused)]
fn main() {
use blinc_layout::flow;
let wetglass = flow!(wetglass, fragment, {
input uv: builtin(uv);
input time: builtin(time);
input resolution: builtin(resolution);
// Gravity gradient: more moisture at bottom
node grav = smoothstep(0.0, 1.0, uv.y);
// Background mist
step mist: pattern_noise { scale: 3.0; detail: 5; animation: time * 0.02; };
node moist = mist * (0.35 + grav * 0.65);
// Multi-scale water drops with aspect correction and gravity scroll
step uv1: transform_wet { aspect: resolution; scroll_speed: 0.001; };
step uv2: transform_wet { aspect: resolution; scroll_speed: 0.0015; offset: vec2(0.38, 0.21); };
step uv3: transform_wet { aspect: resolution; scroll_speed: 0.002; offset: vec2(0.17, 0.63); };
// Worley drops at different scales
step drops1: pattern_worley { uv: uv1; scale: 7.0; threshold: 0.22; edge: 0.05; mask: step(0.3, moist); gradient: true; };
step drops2: pattern_worley { uv: uv2; scale: 12.0; threshold: 0.18; edge: 0.04; mask: step(0.2, moist); gradient: true; };
step drops3: pattern_worley { uv: uv3; scale: 20.0; threshold: 0.13; edge: 0.03; mask: step(0.12, moist); gradient: true; };
// Combine drops
node drops_raw = clamp(drops1 + drops2 * 0.6 + drops3 * 0.3, 0.0, 1.0);
node drops = smoothstep(0.05, 0.4, drops_raw);
// Specular highlights
step highlight: effect_specular {
sources: drops1 drops2 drops3;
weights: 1.0 0.6 0.3;
direction: vec2(0.7071068, 0.7071067);
intensity: 0.25;
power: 64.0;
};
// Fog and lens distortion
node fog = (1.0 - drops) * (0.12 + mist * 0.05);
step lens: effect_refract { source: drops; strength: 0.025; };
// Sample background scene through distorted UVs
node scene = sample_scene(uv + lens);
// Composite
node out_r = scene.x * (1.0 - fog) + fog + highlight;
node out_g = scene.y * (1.0 - fog) + fog + highlight;
node out_b = scene.z * (1.0 - fog) + fog + highlight;
output color = vec4(out_r, out_g, out_b, 0.97);
});
div().flow(wetglass).w(800.0).h(600.0)
}
Output Targets
Each flow target has specific output variables:
Fragment Outputs
| Output | Type | Description |
|---|---|---|
color | vec4 | Fragment color (required) |
alpha | float | Override alpha channel |
displacement | float | SDF displacement |
Compute Outputs
| Output | Type | Description |
|---|---|---|
<buffer>[idx] | varies | Write to named storage buffer |
Vertex Outputs
| Output | Type | Description |
|---|---|---|
position | vec4 | Clip-space position (required) |
world_normal | vec3 | World-space normal to pass to material |
world_position | vec3 | World-space position to pass to material |
Material Outputs
| Output | Type | Description |
|---|---|---|
albedo / base_color | vec4 | Base color RGBA (required) |
metallic | float | Metallic factor (0–1) |
roughness | float | Roughness factor (0–1) |
emissive | vec3 | Emissive color |
surface_normal | vec3 | Overridden surface normal |
alpha_out | float | Alpha override |
3D Flow Shaders
Flow shaders can drive 3D mesh rendering through vertex and material targets. These compile to vertex and fragment shaders that receive mesh geometry data and produce PBR-lit output.
Vertex Shader Flow
Transform vertex positions, apply skeletal animation, or create procedural geometry:
#![allow(unused)]
fn main() {
let vertex_flow = flow!(custom_vertex, vertex, {
input pos: builtin(vertex_position);
input normal: builtin(vertex_normal);
input model: builtin(model_matrix);
input vp: builtin(view_proj);
input time: builtin(time);
// Wave deformation
node wave = sin(pos.x * 4.0 + time * 2.0) * 0.1;
node deformed = vec3(pos.x, pos.y + wave, pos.z);
// Standard MVP transform
node world = mat4_mul_vec4(model, vec4(deformed.x, deformed.y, deformed.z, 1.0));
node clip = mat4_mul_vec4(vp, world);
node w_normal = transform_normal(model, normal);
output position = clip;
output world_normal = w_normal;
output world_position = world.xyz;
});
}
Material Shader Flow
Define surface properties using the DAG — the PBR evaluation is done automatically:
#![allow(unused)]
fn main() {
let material_flow = flow!(pbr_material, material, {
input uv: builtin(uv);
input world_pos: builtin(world_position);
input normal: builtin(world_normal);
input time: builtin(time);
// Procedural texture
node noise = fbm(uv * 8.0, 4);
node base = vec4(0.8 * noise, 0.3, 0.1, 1.0);
// Metallic varies with noise
node metal = smoothstep(0.4, 0.6, noise);
output albedo = base;
output metallic = metal;
output roughness = 0.3;
output emissive = vec3(0.0, 0.0, 0.0);
});
}
CSS-Defined 3D Flows
3D flows work identically in CSS stylesheets:
@flow terrain_vertex {
target: vertex;
input pos: builtin(vertex_position);
input normal: builtin(vertex_normal);
input model: builtin(model_matrix);
input vp: builtin(view_proj);
node world = mat4_mul_vec4(model, vec4(pos.x, pos.y, pos.z, 1.0));
output position = mat4_mul_vec4(vp, world);
output world_normal = transform_normal(model, normal);
output world_position = world.xyz;
}
@flow terrain_material {
target: material;
input uv: builtin(uv);
input normal: builtin(world_normal);
node height = fbm(uv * 10.0, 6);
node grass = vec4(0.2, 0.6, 0.1, 1.0);
node rock = vec4(0.5, 0.45, 0.4, 1.0);
node surface = mix(rock, grass, smoothstep(0.3, 0.6, height));
output albedo = surface;
output roughness = mix(0.8, 0.4, height);
}
Compute → 3D Pipeline
Use compute flows to simulate particle systems, physics, or procedural geometry, then feed the storage buffer data into MeshData:
#![allow(unused)]
fn main() {
// Compute flow updates particle positions
let sim = flow!(particle_sim, compute, {
input time: builtin(time);
buffer positions: vec4 [read_write];
node p = positions[idx];
node new_y = p.y + sin(time + f32(idx) * 0.1) * 0.01;
output positions[idx] = vec4(p.x, new_y, p.z, 1.0);
});
}
The compute output can be read back and used to construct MeshData vertices, or joint matrices for skeletal animation.
Performance Tips
- Analytic gradients:
pattern_worleywithgradient: trueusesworley_grad()which computes distance + gradient in a single 3x3 grid pass (5x faster than finite-difference). - Pipeline caching: Compiled WGSL pipelines are cached by flow name in
FlowPipelineCache. Reusing the same flow name across frames is free after first compile. - Scene copy:
sample_scene()triggers a single texture copy per frame (not per element). Multiple elements sharing a scene-sampling flow share the same copy. - Step expansion: Semantic steps expand to optimized node graphs at parse time, not at render time. There’s zero per-frame overhead from using steps vs raw nodes.
3D Rendering
Blinc provides a GPU-accelerated 3D mesh rendering pipeline alongside its 2D UI. You can render PBR-lit meshes with shadow mapping, normal maps, skeletal animation, and custom shader passes — all within the same frame as your UI elements.
Mesh Data
The interchange format for 3D geometry is MeshData. Users convert from any source format (glTF, OBJ, FBX, procedural) into this struct.
#![allow(unused)]
fn main() {
use blinc_core::{MeshData, Vertex, Material, Mat4};
use std::sync::Arc;
let mesh = MeshData {
vertices: Arc::new(vec![
Vertex::new([-0.5, -0.5, 0.0])
.with_normal([0.0, 0.0, 1.0])
.with_uv([0.0, 0.0])
.with_color([1.0, 0.0, 0.0, 1.0]),
Vertex::new([0.5, -0.5, 0.0])
.with_normal([0.0, 0.0, 1.0])
.with_uv([1.0, 0.0])
.with_color([0.0, 1.0, 0.0, 1.0]),
Vertex::new([0.0, 0.5, 0.0])
.with_normal([0.0, 0.0, 1.0])
.with_uv([0.5, 1.0])
.with_color([0.0, 0.0, 1.0, 1.0]),
]),
indices: Arc::new(vec![0, 1, 2]),
material: Material::default(),
skin: None,
morph_targets: Arc::new(Vec::new()),
morph_weights: Vec::new(),
};
}
Vertex Format
Each vertex contains:
| Field | Type | Description |
|---|---|---|
position | [f32; 3] | XYZ world position |
normal | [f32; 3] | Surface normal (for lighting) |
uv | [f32; 2] | Texture coordinates |
color | [f32; 4] | Per-vertex RGBA color |
tangent | [f32; 4] | Tangent vector for normal mapping (xyz + handedness) |
joints | [u32; 4] | Bone indices for skeletal animation |
weights | [f32; 4] | Bone weights (should sum to 1.0) |
Builder methods chain naturally:
#![allow(unused)]
fn main() {
Vertex::new([0.0, 1.0, 0.0])
.with_normal([0.0, 1.0, 0.0])
.with_uv([0.5, 0.5])
.with_tangent([1.0, 0.0, 0.0, 1.0])
.with_joints([0, 1, 0, 0], [0.7, 0.3, 0.0, 0.0])
}
Materials
The Material struct controls PBR shading:
#![allow(unused)]
fn main() {
use blinc_core::{Material, TextureData, AlphaMode};
let material = Material {
base_color: [0.8, 0.2, 0.1, 1.0], // Red-ish
metallic: 0.0, // Dielectric
roughness: 0.5, // Medium roughness
emissive: [0.0, 0.0, 0.0], // No emission
base_color_texture: None, // Or Some(TextureData { rgba, width, height })
normal_map: None, // Tangent-space normal map
normal_scale: 1.0, // Normal map strength
displacement_map: None, // Height map for parallax
displacement_scale: 0.05, // Displacement depth
unlit: false, // true = skip lighting
alpha_mode: AlphaMode::Opaque,
receives_shadows: true,
casts_shadows: true,
};
}
Textures
Provide texture data as raw RGBA pixels:
#![allow(unused)]
fn main() {
let texture = TextureData {
rgba: my_image_bytes, // Vec<u8>, 4 bytes per pixel
width: 512,
height: 512,
};
let material = Material {
base_color_texture: Some(texture),
..Material::default()
};
}
Normal Mapping
Normal maps add surface detail without extra geometry. The shader uses the vertex tangent and bitangent to transform tangent-space normals to world space.
#![allow(unused)]
fn main() {
let material = Material {
normal_map: Some(TextureData {
rgba: normal_map_pixels,
width: 1024,
height: 1024,
}),
normal_scale: 1.5, // Exaggerate the effect
..Material::default()
};
}
Parallax Displacement
Height maps create the illusion of depth through parallax occlusion mapping (16-layer raymarching in the fragment shader):
#![allow(unused)]
fn main() {
let material = Material {
displacement_map: Some(TextureData {
rgba: height_map_pixels, // Grayscale encoded as RGBA
width: 512,
height: 512,
}),
displacement_scale: 0.1, // World-space depth
..Material::default()
};
}
Alpha Modes and Transparency
Every material declares one of three AlphaModes:
| Mode | Depth write | Blend | Use for |
|---|---|---|---|
Opaque | yes | replace | solid surfaces (default) |
Mask | yes | alpha-test (discard below alpha_cutoff) | hard cutouts — foliage, hair strands, decals with binary alpha |
Blend | no | weighted blended OIT (see below) | genuine translucency — glass, smoke, soft edges |
OIT, not back-to-front sort. AlphaMode::Blend routes through Weighted Blended OIT (McGuire & Bavoil 2013). Every BLEND fragment writes into an accumulation texture and a transmission texture, and a composite pass divides-and-blends the result over the opaque HDR buffer at end of frame. Callers don’t need to sort meshes back-to-front — the renderer handles overlapping BLEND layers statistically.
Submission order doesn’t matter at the API boundary. dispatch_pending_meshes stable-sorts OPAQUE + MASK before BLEND before handing to the renderer, so you can call draw_mesh_data in scene-graph order. (This matters because WBOIT requires every opaque depth to be written before any BLEND fragment runs its depth test — otherwise BLEND pixels that should be occluded by a later-dispatched opaque mesh leak into the composite. The framework sort is what lets you ignore this invariant.)
glTF loader auto-demotes misflagged BLEND. Many DCC exporters flag every material as BLEND by default. blinc_gltf::parse_material analyses each base-color texture’s alpha histogram on load:
| Texture profile | Demoted to | Reason |
|---|---|---|
| ≥95% texels at α ≥ 0.95 | Opaque | dense coverage, no meaningful translucency |
| ≥99% texels at α ≤ 0.05 or α ≥ 0.95, <1% midrange | Mask | strict binary cutout |
| anything else | stays Blend | genuine partial alpha |
Decisions log at info level — run any demo with RUST_LOG=blinc_gltf=info to see per-material authored=Blend resolved=Opaque lines. A material whose BLEND looks wrong usually means either the heuristic matched poorly (report it) or the asset really is authored that way.
OIT’s limitation. WBOIT approximates a weighted average of overlapping BLEND fragments — it can’t perfectly resolve stacked translucent layers at the same depth. In practice this is invisible for single-layer BLEND (most assets) and for sparse translucent overlays (eyelashes, tearlines, decals). Dense BLEND stacks (e.g. foliage with many overlapping leaves) may look slightly washed compared to correct back-to-front sorting; moving such assets to Mask when the alpha is binary fixes it.
Drawing in Canvas
Use draw_mesh_data on the DrawContext inside a canvas element:
#![allow(unused)]
fn main() {
canvas(|ctx: &mut dyn DrawContext, bounds| {
ctx.draw_mesh_data(&mesh, Mat4::IDENTITY);
})
.w(800.0)
.h(600.0)
}
The Mat4 transform positions the mesh in the scene. The renderer handles vertex/index buffer upload and PBR shading automatically.
Shadow Mapping
The mesh pipeline includes a shadow depth pass. When rendering via GpuRenderer::render_mesh_data(), pass a light_view_proj matrix to enable shadows:
#![allow(unused)]
fn main() {
// Orthographic light projection for directional shadows
let light_view_proj: [f32; 16] = compute_light_matrix(light_dir, scene_bounds);
renderer.render_mesh_data(
&target_view,
&mesh,
&model_matrix,
&view_proj,
camera_pos,
light_dir,
1.0, // light intensity
Some(&light_view_proj), // enables shadow pass
);
}
The shadow system uses:
- 2048x2048 depth texture (Depth32Float)
- Front-face culling in shadow pass (reduces shadow acne)
- Depth bias (constant=2, slope_scale=2.0) for further acne reduction
- 4-tap PCF sampling for soft shadow edges
Materials control shadow behavior per-mesh:
#![allow(unused)]
fn main() {
let floor = Material {
receives_shadows: true, // Shadows appear on this surface
casts_shadows: false, // This mesh doesn't cast shadows
..Material::default()
};
}
Skeletal Animation
Animate meshes with bone transforms. The GPU applies per-vertex skinning using up to 4 joint influences.
Skeleton Definition
#![allow(unused)]
fn main() {
use blinc_core::{Bone, Skeleton, SkinningData};
let skeleton = Skeleton {
bones: vec![
Bone {
name: "Root".into(),
parent: None,
inverse_bind_matrix: identity_matrix(),
},
Bone {
name: "UpperArm".into(),
parent: Some(0),
inverse_bind_matrix: upper_arm_ibm,
},
Bone {
name: "LowerArm".into(),
parent: Some(1),
inverse_bind_matrix: lower_arm_ibm,
},
],
};
}
Per-Frame Skinning
Each frame, compute the joint matrices and attach them to the mesh:
#![allow(unused)]
fn main() {
// joint_matrix[i] = current_world_transform[i] * inverse_bind_matrix[i]
let joint_matrices: Vec<[f32; 16]> = skeleton.bones.iter()
.enumerate()
.map(|(i, bone)| {
multiply_mat4(&animated_world_transforms[i], &bone.inverse_bind_matrix)
})
.collect();
let mesh = MeshData {
vertices: Arc::new(skinned_vertices), // vertices with .joints and .weights set
indices: Arc::new(indices),
material: Material::default(),
skin: Some(SkinningData { joint_matrices }),
morph_targets: Arc::new(Vec::new()),
morph_weights: Vec::new(),
};
}
Vertex Skinning
Vertices reference bones by index:
#![allow(unused)]
fn main() {
Vertex::new([0.0, 1.0, 0.0])
.with_joints(
[0, 1, 0, 0], // bone indices
[0.6, 0.4, 0.0, 0.0] // weights (sum to 1.0)
)
}
The GPU vertex shader computes:
skin_matrix = joint[0] * w0 + joint[1] * w1 + joint[2] * w2 + joint[3] * w3
position = skin_matrix * vertex_position
normal = skin_matrix * vertex_normal
Maximum 256 joints per mesh, stored in a GPU storage buffer.
Custom Render Passes
Inject your own GPU render passes into the pipeline. Passes execute at specific stages — before UI rendering (PreRender) or after (PostProcess).
Basic Custom Pass
#![allow(unused)]
fn main() {
use blinc_gpu::{CustomRenderPass, RenderPassContext, RenderStage};
struct SkyboxPass {
pipeline: Option<wgpu::RenderPipeline>,
}
impl CustomRenderPass for SkyboxPass {
fn label(&self) -> &str { "skybox" }
fn stage(&self) -> RenderStage { RenderStage::PreRender }
fn initialize(&mut self, device: &wgpu::Device, _queue: &wgpu::Queue, format: wgpu::TextureFormat) {
// Create your render pipeline, bind groups, etc.
}
fn render(&mut self, ctx: &RenderPassContext) {
let mut encoder = ctx.device.create_command_encoder(&Default::default());
{
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Skybox"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: ctx.target,
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Load,
store: wgpu::StoreOp::Store,
},
})],
..Default::default()
});
// Draw skybox...
}
ctx.queue.submit(std::iter::once(encoder.finish()));
}
}
// Register with the renderer
renderer.register_custom_pass(Box::new(SkyboxPass { pipeline: None }));
}
Render Stages
| Stage | When | Use Cases |
|---|---|---|
PreRender | Before UI primitives | Skyboxes, 3D scene backgrounds, grid overlays |
PostProcess | After all UI rendering | Bloom, tone mapping, FXAA, vignette, debug overlays |
Custom Bind Groups
The BindGroupBuilder creates matched layout + bind group pairs:
#![allow(unused)]
fn main() {
use blinc_gpu::BindGroupBuilder;
let mut builder = BindGroupBuilder::new("my_effect");
builder.add_uniform_buffer(uniforms_buffer.as_entire_binding());
builder.add_texture(&my_texture_view);
builder.add_sampler(&my_sampler);
builder.add_storage_buffer(data_buffer.as_entire_binding(), true); // read-only
let (layout, bind_group) = builder.build(device);
}
Supported binding types:
| Method | Shader Type | Notes |
|---|---|---|
add_uniform_buffer() | var<uniform> | Per-frame data (transforms, time, etc.) |
add_storage_buffer(_, read_only) | var<storage> | Large data arrays, particle buffers |
add_texture() | texture_2d<f32> | Sampled textures (filterable) |
add_storage_texture() | texture_storage_2d | Compute write targets |
add_sampler() | sampler | Filtering sampler |
add_comparison_sampler() | sampler_comparison | Shadow map sampling |
Compute Shaders
Execute compute shaders for simulation, particle updates, or data processing:
#![allow(unused)]
fn main() {
use blinc_gpu::{create_compute_pipeline, ComputeDispatch, BindGroupBuilder};
// Create pipeline from WGSL
let pipeline = create_compute_pipeline(
device,
"particle_sim",
include_str!("shaders/particle_sim.wgsl"),
"cs_main",
&bind_group_layout,
);
// Dispatch
let dispatch = ComputeDispatch {
pipeline: &pipeline,
bind_group: &bind_group,
workgroups: (particle_count / 64, 1, 1),
label: "particle_sim",
};
dispatch.execute(device, queue);
}
Post-Processing Chain
Chain multiple screen-space effects with automatic ping-pong texture management:
#![allow(unused)]
fn main() {
use blinc_gpu::{PostProcessChain, PostProcessEffect};
struct BloomEffect { /* ... */ }
impl PostProcessEffect for BloomEffect {
fn label(&self) -> &str { "bloom" }
fn initialize(&mut self, device: &wgpu::Device, _queue: &wgpu::Queue, format: wgpu::TextureFormat) {
// Create bloom pipeline, intermediate textures, etc.
}
fn apply(&mut self, device: &wgpu::Device, queue: &wgpu::Queue,
input: &wgpu::TextureView, output: &wgpu::TextureView,
width: u32, height: u32) {
// Read from input, write bloom result to output
}
}
// Build a chain
let mut chain = PostProcessChain::new("my_effects");
chain.add_effect(Box::new(BloomEffect::new()));
chain.add_effect(Box::new(ToneMappingEffect::new()));
// Register as a custom pass (runs at PostProcess stage)
renderer.register_custom_pass(Box::new(chain));
}
The chain automatically:
- Copies the framebuffer to a ping texture
- Chains effects: ping → pong → ping → … → framebuffer
- Manages texture lifetimes and resizing
- Skips disabled effects
Flow Shader Integration
The flow shader system extends beyond 2D effects — it is a general-purpose DAG compute system that compiles to WGSL for any target. For 3D rendering, use vertex and material flow targets.
Declarative Vertex Shader
Instead of writing raw WGSL, define vertex transforms as a flow DAG:
#![allow(unused)]
fn main() {
use blinc_layout::flow;
let wave_vertex = flow!(wave_vertex, vertex, {
input pos: builtin(vertex_position);
input normal: builtin(vertex_normal);
input model: builtin(model_matrix);
input vp: builtin(view_proj);
input time: builtin(time);
node wave = sin(pos.x * 4.0 + time * 2.0) * 0.1;
node deformed = vec3(pos.x, pos.y + wave, pos.z);
node world = mat4_mul_vec4(model, vec4(deformed.x, deformed.y, deformed.z, 1.0));
output position = mat4_mul_vec4(vp, world);
output world_normal = transform_normal(model, normal);
output world_position = world.xyz;
});
}
Declarative Material Shader
Define PBR surface properties — the flow compiler injects Blinn-Phong + Fresnel evaluation automatically:
#![allow(unused)]
fn main() {
let terrain_mat = flow!(terrain_mat, material, {
input uv: builtin(uv);
input normal: builtin(world_normal);
node height = fbm(uv * 10.0, 6);
node grass = vec4(0.2, 0.6, 0.1, 1.0);
node rock = vec4(0.5, 0.45, 0.4, 1.0);
output albedo = mix(rock, grass, smoothstep(0.3, 0.6, height));
output roughness = mix(0.8, 0.4, height);
output metallic = 0.0;
});
}
CSS-Defined 3D Shaders
Flow shaders for 3D work in CSS stylesheets too:
@flow ocean_vertex {
target: vertex;
input pos: builtin(position);
input model: builtin(model);
input vp: builtin(view_proj);
input time: builtin(time);
node wave = sin(pos.x * 3.0 + time) * 0.2 + sin(pos.z * 2.0 + time * 1.3) * 0.15;
node displaced = vec3(pos.x, pos.y + wave, pos.z);
node world = mat4_mul_vec4(model, vec4(displaced.x, displaced.y, displaced.z, 1.0));
output position = mat4_mul_vec4(vp, world);
output world_position = world.xyz;
}
Tip: See the Flow Shaders chapter for the complete function reference, semantic steps, chains, and composition with
use.
Raw Pixel Drawing
For video frames, camera previews, or procedural textures, use draw_rgba_pixels:
#![allow(unused)]
fn main() {
canvas(|ctx: &mut dyn DrawContext, bounds| {
// Upload and render RGBA pixel data in one call
ctx.draw_rgba_pixels(
&rgba_data, // &[u8], 4 bytes per pixel
width, // u32
height, // u32
Rect::new(0.0, 0.0, bounds.width, bounds.height),
);
})
.w(640.0)
.h(480.0)
}
This creates a GPU texture each frame — ideal for dynamic content like video playback or camera streams.
GPU Memory Budget
The renderer tracks GPU texture memory and enforces a configurable budget:
#![allow(unused)]
fn main() {
// Default: 128 MB, override with BLINC_GPU_MEMORY_BUDGET_MB env var
let config = RendererConfig {
gpu_memory_budget: 256 * 1024 * 1024, // 256 MB
..RendererConfig::default()
};
}
Call renderer.enforce_memory_budget() once per frame to evict cached textures when over budget. Eviction is largest-first (XLarge pool textures → mask image cache).
Architecture
The 3D rendering pipeline sits alongside the 2D SDF pipeline:
Frame
├── PreRender custom passes (skybox, 3D scene)
├── UI Rendering
│ ├── SDF primitives (2D shapes)
│ ├── Glass / vibrancy effects
│ ├── Text glyphs
│ ├── Canvas callbacks → draw_mesh_data / draw_rgba_pixels
│ └── Layer effects (blur, shadow, glow)
├── PostProcess custom passes (bloom, tone mapping)
└── Memory budget enforcement
The mesh pipeline (MeshPipeline) is lazily created on first use and includes:
- Main PBR render pipeline with normal/displacement/shadow support
- Shadow depth pass pipeline (Depth32Float, front-face culling)
- Default textures (white, flat normal, black displacement)
- Joint matrix storage buffer (for skeletal animation)
- Comparison sampler (for PCF shadow sampling)
Tip: For static 3D scenes, render to an offscreen texture once, then display it as an image. Only re-render when the camera or scene changes.
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::<ButtonState>()
.deps([count.signal_id()]) // Declare dependency
.on_state(move |ctx| {
// Reading count.get() here is tracked
let value = count.get();
div().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(count: State<i32>) -> impl ElementBuilder {
stateful::<NoState>()
// Declare signal dependencies
.deps([count.signal_id()])
.on_state(move |_ctx| {
// This callback re-runs when count changes
let current = count.get();
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);
}
}
Writing a Cross-Target Example
Every example under examples/blinc_app_examples/examples/
runs on every platform Blinc supports — desktop via
WindowedApp::run, web via WebApp::run_with_setup, and (where
the widgets allow) iOS and Android via the mobile runners — with
no per-target forks. A single source file is the source of truth
for all targets.
The Example Gallery is assembled from
this same set, auto-discovered by tools/build-web-examples and
published to GitHub Pages via CI. Adding a new example requires
writing one file that follows the convention below. Nothing else.
No manifest entry. No wrapper crate. No CI change.
The convention
Every cross-target example must define exactly one function with
this signature, as a top-level pub fn:
#![allow(unused)]
fn main() {
pub fn build_ui(ctx: &mut WindowedContext) -> impl ElementBuilder {
// The actual demo UI.
}
}
And its fn main must be cfg-gated to non-wasm targets:
#[cfg(not(target_arch = "wasm32"))]
fn main() -> blinc_app::Result<()> {
tracing_subscriber::fmt()
.with_max_level(tracing::Level::INFO)
.init();
let config = WindowConfig {
title: "My Example".to_string(),
width: 800,
height: 600,
..Default::default()
};
blinc_app::windowed::WindowedApp::run(config, build_ui)
}
That’s it. Run the codegen tool:
cargo run -p blinc-build-web-examples
Your example is now auto-discovered, wrapped as a wasm32 crate under
examples/_generated/<name>/, built by CI, and appears in the
Example Gallery with the title and
description pulled from your //! doc comment.
The full template
A complete minimal example looks like this:
//! My New Example
//!
//! One-paragraph description of what the demo shows. This text
//! becomes the gallery page description verbatim — keep it short.
//! Bullet points render fine:
//! - First thing the example demonstrates
//! - Second thing
//!
//! Run with: cargo run -p blinc_app_examples --example my_new --features windowed
use blinc_app::prelude::*;
use blinc_app::windowed::WindowedContext;
#[cfg(not(target_arch = "wasm32"))]
fn main() -> Result<()> {
tracing_subscriber::fmt()
.with_max_level(tracing::Level::INFO)
.init();
let config = WindowConfig {
title: "My New Example".to_string(),
width: 800,
height: 600,
..Default::default()
};
blinc_app::windowed::WindowedApp::run(config, build_ui)
}
pub fn build_ui(ctx: &mut WindowedContext) -> impl ElementBuilder {
div()
.w(ctx.width)
.h(ctx.height)
.bg(Color::rgba(0.08, 0.08, 0.12, 1.0))
.items_center()
.justify_center()
.child(
text("Hello, Blinc!")
.size(32.0)
.color(Color::WHITE),
)
}
Save that as examples/blinc_app_examples/examples/my_new.rs and run
cargo run -p blinc-build-web-examples. The gallery picks it up
on the next book build.
What the codegen tool extracts from your file
- Title — the first non-empty line of the
//!doc block. The “ Example“ / “ Demo“ suffix is stripped for display, so//! Scroll Container Examplebecomes Scroll Container. - Description — everything from the second
//!paragraph up to (but not including) the firstRun with:line. Rendered verbatim as markdown on the gallery page. - Dependencies — the tool greps your source for
blinc_cn::/blinc_icons::/blinc_tabler_icons::/blinc_canvas_kit::/blinc_theme::/ etc. and adds matchingpath = "..."dependencies to the generated wrapper’sCargo.toml. If you use a workspace crate the tool doesn’t know about yet, add it to theINFERABLE_DEPStable intools/build-web-examples/src/main.rs.
Constraints
The return type must be impl ElementBuilder, not Div
impl ElementBuilder lets you return anything Blinc considers a
valid root element: Div, Scroll, Stateful<T>, Canvas,
MotionContainer, etc. Returning Div specifically would force
you to wrap non-Div roots like scroll().child(...) in an
extra div().child(...) just to satisfy the type system, which
adds a pointless layout node.
The web runner (WebApp::run_with_setup) accepts any
ElementBuilder via the internal UiBuilderFn trait — see
crates/blinc_app/src/web.rs
for the type-erasure machinery. You should never need to think
about it; just return whatever feels natural.
ctx must be &mut WindowedContext, not &WindowedContext
The web runner’s frame loop holds a mutable borrow of the context
for reactive state bookkeeping. Taking &mut makes your build_ui
compatible with both WindowedApp::run (desktop) and
WebApp::run_with_setup (web); taking & only works on desktop.
fn main must be #[cfg(not(target_arch = "wasm32"))]-gated
Without the cfg gate, cargo check --target wasm32-unknown-unknown
would compile your WindowedApp::run call into a wasm binary, and
that method isn’t available on the web target. The gate also means
the auto-generated wrapper crate can include! your example source
without colliding with its own #[wasm_bindgen(start)] entry
point.
State initialization goes inside build_ui, not before it
Historically a lot of the framework’s examples initialized an
Arc<Mutex<...>> or a timeline in fn main and captured it into
the closure passed to WindowedApp::run. That pattern doesn’t
translate to the web target, because the wasm wrapper only has
access to build_ui — it never sees whatever state fn main
set up. Put the state setup inside build_ui and use
ctx.use_state_keyed / ctx.use_animated_timeline to persist it
across rebuilds:
#![allow(unused)]
fn main() {
pub fn build_ui(ctx: &mut WindowedContext) -> impl ElementBuilder {
// Persistent state keyed by string — survives rebuilds.
let count = ctx.use_state_keyed("counter", || 0i32);
// Persistent animation timeline — also survives rebuilds.
let timeline = ctx.use_animated_timeline();
div().child(/* ... use count and timeline ... */)
}
}
Opting out
Some examples can’t run on the web target — multi-window demos,
CLI diagnostics, OS-specific runners. For these, add a //! no-web:
line with a short reason to the top of the doc block:
#![allow(unused)]
fn main() {
//! Multi-Window Demo
//!
//! no-web: the web target has no multi-window concept — a browser
//! tab is a single `<canvas>`. `open_window_with()` doesn't
//! translate to the browser. Kept desktop-only on purpose.
//!
//! Demonstrates: ...
}
The codegen tool skips any file with no-web: in its doc block
(no wrapper crate, no gallery entry) without erroring out. The
desktop build is untouched, and the example continues to work as
cargo run -p blinc_app_examples --example <name> --features windowed.
Currently opted out:
css_parser_demo— CLI diagnostic, no event loopfuchsia_hello— Fuchsia OS targetmulti_window_demo— multi-window not supported on web
Running locally
Desktop:
cargo run -p blinc_app_examples --example my_new --features windowed
Unchanged from before the cross-target convention.
Web:
# 1. Generate (or regenerate) the wasm wrapper crate
cargo run -p blinc-build-web-examples
# 2. Build it with wasm-pack
cd examples/_generated/my_new
wasm-pack build --target web --release
# 3. Serve it
./serve.sh 8000
# Open http://localhost:8000 in Chrome 113+
For iterating on a single example, once the wrapper exists you can
skip step 1 on subsequent runs — cargo’s rerun-if-changed in the
wrapper’s build.rs catches edits to your upstream example
automatically. Only add / remove / rename operations require a
fresh codegen pass.
What the tool generates
Running cargo run -p blinc-build-web-examples (no flags) writes:
examples/_generated/<name>/— one wrapper crate per discovered example. Contents:Cargo.toml,build.rs,src/lib.rs,index.html,serve.sh,.gitignore.docs/book/src/web/example-gallery.md— the gallery index.docs/book/src/web/example-gallery/<name>.md— one page per example with an iframe of the wasm build.docs/book/src/SUMMARY.md— patched between<!-- begin:web-examples -->/<!-- end:web-examples -->markers to include the new gallery pages in the book’s TOC.
Everything under examples/_generated/ is gitignored (except
.gitignore + .gitkeep markers) so the generated tree is
rebuilt on every CI run and never ends up in a commit.
Flags:
--build— after codegen, runwasm-pack build --target web --releasein each wrapper. Used by CI.--stage-to <dir>— after--build, copy each wrapper’sindex.html+pkg/into<dir>/<name>/. Used by CI to drop artifacts intotarget/book/examples/for mdBook iframe resolution. Implies--build.--no-gallery— skip the markdown + SUMMARY patch. Useful for lint-only CI steps that don’t need to touch the book source.
Why this design
The earlier version of the repo had hand-written wrapper crates
for every web example (examples/web_hello, web_drag,
web_assets, web_mobile_demo). That worked for a handful but
didn’t scale: every new example meant a new directory, new
Cargo.toml, new index.html, new serve.sh, plus duplicated
code between the desktop and web entry points.
The convention-driven approach collapses all that to one .rs
file that compiles for both targets. The wrapper crate generation
is purely mechanical: the codegen tool parses your example with
syn, checks for the convention, and emits the wrapper from a
template. There’s no magic, no AST rewriting — just file I/O.