Introduction
Blinc is a GPU-accelerated, reactive UI framework for Rust. It provides a declarative, component-based approach to building high-performance user interfaces with smooth animations and modern visual effects.
Why Blinc?
-
GPU-Accelerated Rendering - All rendering is done on the GPU via wgpu, enabling smooth 60fps animations and complex visual effects like glass materials and shadows.
-
Declarative UI - Build interfaces using a fluent, composable API inspired by SwiftUI and modern web frameworks. No manual DOM manipulation.
-
Reactive State - Automatic UI updates when state changes, with fine-grained reactivity for optimal performance.
-
Spring Physics - Natural, physics-based animations using spring dynamics instead of fixed durations.
-
Cross-Platform - Runs on macOS, Windows, Linux, and Android (iOS coming soon).
Key Features
Flexbox Layout
All layout is powered by Taffy, a high-performance flexbox implementation. Use familiar CSS-like properties:
#![allow(unused)]
fn main() {
div()
.flex_col()
.gap(16.0)
.p(24.0)
.child(text("Hello"))
.child(text("World"))
}
Material Effects
Built-in support for glass, metallic, and other material effects:
#![allow(unused)]
fn main() {
div()
.glass()
.rounded(16.0)
.p(24.0)
.child(text("Frosted Glass"))
}
Type-Safe Animations
The BlincComponent derive macro generates type-safe animation hooks:
#![allow(unused)]
fn main() {
#[derive(BlincComponent)]
struct MyCard {
#[animation]
scale: f32,
#[animation]
opacity: f32,
}
// Usage
let scale = MyCard::use_scale(ctx, 1.0, SpringConfig::snappy());
let opacity = MyCard::use_opacity(ctx, 0.0, SpringConfig::gentle());
}
Event Handling
Intuitive event handling with closures:
#![allow(unused)]
fn main() {
div()
.on_click(|_| println!("Clicked!"))
.on_hover_enter(|_| println!("Hovered"))
}
Architecture Overview
┌─────────────────────────────────────────────────────┐
│ Your Application │
├─────────────────────────────────────────────────────┤
│ blinc_app │ WindowedApp, Context, State Hooks │
├──────────────┼──────────────────────────────────────┤
│ blinc_layout│ Elements, Flexbox, Event Routing │
├──────────────┼──────────────────────────────────────┤
│ blinc_animation │ Springs, Timelines, Motion │
├──────────────┼──────────────────────────────────────┤
│ blinc_gpu │ Render Pipeline, Materials │
├──────────────┼──────────────────────────────────────┤
│ wgpu │ GPU Abstraction Layer │
└─────────────────────────────────────────────────────┘
Quick Example
Here’s a minimal Blinc application:
use blinc_app::prelude::*;
use blinc_app::windowed::{WindowedApp, WindowedContext};
fn main() -> Result<()> {
WindowedApp::run(WindowConfig::default(), |ctx| {
div()
.w(ctx.width)
.h(ctx.height)
.bg(Color::rgba(0.1, 0.1, 0.15, 1.0))
.flex_center()
.child(
div()
.glass()
.rounded(16.0)
.p(32.0)
.child(text("Hello, Blinc!").size(24.0).color(Color::WHITE))
)
})
}
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
crates/blinc_app/examples/directory which includeswindowed.rs,canvas_demo.rs,motion_demo.rs, and more.
Key Concepts Learned
- WindowedApp::run - Entry point for desktop applications
- WindowedContext - Provides window dimensions and state hooks
- use_state_keyed - Creates reactive state with a string key
- stateful::<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 platforms. The same Rust UI code runs on mobile with platform-specific rendering backends (Vulkan for Android, Metal for iOS).
Cross-Platform Architecture
┌─────────────────────────────────────────────────────────────┐
│ Your Blinc App │
│ (Shared Rust UI code, state, animations) │
└─────────────────────────────┬───────────────────────────────┘
│
┌────────────────────┼────────────────────┐
│ │ │
┌────▼────┐ ┌─────▼─────┐ ┌────▼────┐
│ Desktop │ │ Android │ │ iOS │
│ (wgpu) │ │ (Vulkan) │ │ (Metal) │
└─────────┘ └───────────┘ └─────────┘
Key Features
- Shared UI Code: Write your UI once in Rust, deploy everywhere
- Native Performance: GPU-accelerated rendering via Vulkan/Metal
- Touch Support: Full multi-touch gesture handling
- Reactive State: Same reactive state system as desktop
- Animations: Spring physics and keyframe animations work seamlessly
Supported Platforms
| Platform | Backend | Min Version | Status |
|---|---|---|---|
| Android | Vulkan | API 24 (7.0) | Stable |
| iOS | Metal | iOS 15+ | Stable |
Project Structure
A typical Blinc mobile project looks like this:
my-app/
├── Cargo.toml # Rust dependencies
├── blinc.toml # Blinc project config
├── src/
│ └── main.rs # Shared UI code
├── platforms/
│ ├── android/ # Android-specific files
│ │ ├── app/
│ │ │ └── src/main/
│ │ │ ├── AndroidManifest.xml
│ │ │ └── kotlin/.../MainActivity.kt
│ │ └── build.gradle.kts
│ └── ios/ # iOS-specific files
│ ├── BlincApp/
│ │ ├── AppDelegate.swift
│ │ ├── BlincViewController.swift
│ │ └── Info.plist
│ └── BlincApp.xcodeproj/
└── build-android.sh # Build scripts
Quick Start
1. Create a new mobile project
blinc new my-app --template rust
cd my-app
2. Write your UI
#![allow(unused)]
fn main() {
use blinc_app::prelude::*;
fn app(ctx: &mut WindowedContext) -> impl ElementBuilder {
let count = ctx.use_state_keyed("count", || 0i32);
div()
.w(ctx.width)
.h(ctx.height)
.bg(Color::from_hex(0x1a1a2e))
.flex_col()
.items_center()
.justify_center()
.gap(20.0)
.child(counter_display(count.clone()))
.child(counter_button("+", count.clone(), 1))
}
fn counter_display(count: State<i32>) -> impl ElementBuilder {
// Stateful elements with deps update incrementally when dependencies change
stateful::<NoState>()
.deps([count.signal_id()])
.on_state(move |_ctx| {
text(format!("Count: {}", count.get()))
.size(48.0)
.color(Color::WHITE)
})
}
fn counter_button(label: &str, count: State<i32>, delta: i32) -> impl ElementBuilder {
let label = label.to_string();
stateful::<ButtonState>()
.on_state(move |ctx| {
let bg = match ctx.state() {
ButtonState::Idle => Color::from_hex(0x4a4a5a),
ButtonState::Hovered => Color::from_hex(0x5a5a6a),
ButtonState::Pressed => Color::from_hex(0x3a3a4a),
ButtonState::Disabled => Color::from_hex(0x2a2a2a),
};
div()
.w(80.0).h(50.0)
.rounded(8.0)
.bg(bg)
.items_center()
.justify_center()
.child(text(&label).size(24.0).color(Color::WHITE))
})
.on_click(move |_| count.set(count.get() + delta))
}
}
3. Build and run
# Android
blinc run android
# iOS
blinc run ios
Next Steps
- Android Development - Set up Android toolchain and build
- iOS Development - Set up iOS toolchain and build
- CLI Reference - Full CLI command reference
Android Development
This guide covers setting up your environment and building Blinc apps for Android.
Prerequisites
1. Android SDK & NDK
Install Android Studio or the standalone SDK:
# macOS (via Homebrew)
brew install --cask android-studio
# Or download from https://developer.android.com/studio
Set up environment variables:
export ANDROID_HOME=$HOME/Library/Android/sdk
export ANDROID_NDK_HOME=$ANDROID_HOME/ndk/26.1.10909125
export PATH=$PATH:$ANDROID_HOME/platform-tools
2. Rust Android Targets
rustup target add aarch64-linux-android
rustup target add armv7-linux-androideabi
rustup target add x86_64-linux-android
rustup target add i686-linux-android
3. cargo-ndk
cargo install cargo-ndk
Building
Debug Build
# Build for arm64 (most modern devices)
cargo ndk -t arm64-v8a build
# Build for multiple architectures
cargo ndk -t arm64-v8a -t armeabi-v7a build
Release Build
cargo ndk -t arm64-v8a build --release
Using Gradle
From the platforms/android directory:
./gradlew assembleDebug
The APK will be at app/build/outputs/apk/debug/app-debug.apk.
Project Configuration
Cargo.toml
[lib]
name = "my_app"
crate-type = ["cdylib", "staticlib"]
[target.'cfg(target_os = "android")'.dependencies]
blinc_app = { version = "0.1", features = ["android"] }
blinc_platform_android = "0.1"
android-activity = { version = "0.6", features = ["native-activity"] }
log = "0.4"
android_logger = "0.14"
AndroidManifest.xml
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-feature android:glEsVersion="0x00030000" android:required="true" />
<application
android:label="My App"
android:theme="@android:style/Theme.DeviceDefault.NoActionBar.Fullscreen"
android:hardwareAccelerated="true">
<activity
android:name=".MainActivity"
android:configChanges="orientation|screenSize|keyboardHidden"
android:exported="true">
<meta-data
android:name="android.app.lib_name"
android:value="my_app" />
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
Touch Event Handling
Android touch events are automatically routed to your UI. The touch phases map as follows:
| Android Action | Blinc Event |
|---|---|
| ACTION_DOWN | pointer_down |
| ACTION_MOVE | pointer_move |
| ACTION_UP | pointer_up + pointer_leave |
| ACTION_CANCEL | pointer_leave |
Two-finger pinch gestures emit the layout PINCH event with the gesture center
and per-frame scale delta. One-finger drag scrolling is unchanged.
Debugging
View Logs
adb logcat | grep -E "(blinc|BlincApp)"
Common Issues
“Library not found”
Ensure the native library is built and copied to app/src/main/jniLibs/:
cargo ndk -t arm64-v8a build
cp target/aarch64-linux-android/debug/libmy_app.so \
platforms/android/app/src/main/jniLibs/arm64-v8a/
“Vulkan not supported”
Check device compatibility:
adb shell getprop ro.hardware.vulkan
Most devices with API 24+ support Vulkan, but some older devices may not.
Touch events not working
- Verify the render context is created successfully
- Check that
android.app.lib_namein manifest matches your library name - Look for errors in logcat
Performance Tips
- Use release builds for performance testing
- Enable LTO in Cargo.toml:
[profile.release] lto = "thin" opt-level = "z" - Test on real devices - emulators have different GPU characteristics
iOS Development
This guide covers setting up your environment and building Blinc apps for iOS.
Prerequisites
1. Xcode
Install Xcode 15+ from the Mac App Store or Apple Developer website.
# Verify installation
xcode-select -p
2. Rust iOS Targets
rustup target add aarch64-apple-ios # Device (arm64)
rustup target add aarch64-apple-ios-sim # Simulator (Apple Silicon)
rustup target add x86_64-apple-ios # Simulator (Intel)
Building
Build Script
Create a build script build-ios.sh:
#!/bin/bash
set -e
MODE=${1:-debug}
PROJECT_NAME="my_app"
if [ "$MODE" = "release" ]; then
CARGO_FLAGS="--release"
TARGET_DIR="release"
else
CARGO_FLAGS=""
TARGET_DIR="debug"
fi
# Build for device
cargo build --target aarch64-apple-ios $CARGO_FLAGS
# Build for simulator (Apple Silicon)
cargo build --target aarch64-apple-ios-sim $CARGO_FLAGS
# Copy to libs directory
mkdir -p platforms/ios/libs/device
mkdir -p platforms/ios/libs/simulator
cp target/aarch64-apple-ios/$TARGET_DIR/lib${PROJECT_NAME}.a \
platforms/ios/libs/device/
cp target/aarch64-apple-ios-sim/$TARGET_DIR/lib${PROJECT_NAME}.a \
platforms/ios/libs/simulator/
Building
# Debug build
./build-ios.sh
# Release build
./build-ios.sh release
Xcode
- Open
platforms/ios/BlincApp.xcodeproj - Select your target (device or simulator)
- Press Cmd+R to build and run
Project Configuration
Cargo.toml
[lib]
name = "my_app"
crate-type = ["cdylib", "staticlib"]
[target.'cfg(target_os = "ios")'.dependencies]
blinc_app = { version = "0.1", features = ["ios"] }
blinc_platform_ios = "0.1"
Xcode Build Settings
In your Xcode project:
-
Link the static library:
- Build Phases → Link Binary With Libraries
- Add
libmy_app.afromlibs/device/orlibs/simulator/
-
Set the bridging header:
- Build Settings → Swift Compiler - General
- Objective-C Bridging Header:
BlincApp/Blinc-Bridging-Header.h
-
Add required frameworks:
- Metal.framework
- MetalKit.framework
- QuartzCore.framework
Swift Integration
Bridging Header
The bridging header (Blinc-Bridging-Header.h) declares the C FFI functions:
// Context lifecycle
IOSRenderContext* blinc_create_context(uint32_t width, uint32_t height, double scale);
void blinc_destroy_context(IOSRenderContext* ctx);
// Rendering
bool blinc_needs_render(IOSRenderContext* ctx);
void blinc_build_frame(IOSRenderContext* ctx);
bool blinc_render_frame(IOSGpuRenderer* gpu);
// Input
void blinc_handle_touch(IOSRenderContext* ctx, uint64_t id, float x, float y, int32_t phase);
View Controller
The BlincViewController manages:
- CADisplayLink for 60fps frame timing
- Metal layer for GPU rendering
- Touch event forwarding to Rust
Touch Event Handling
iOS touch events are routed through the view controller:
| iOS Phase | Blinc Event |
|---|---|
| touchesBegan | pointer_down |
| touchesMoved | pointer_move |
| touchesEnded | pointer_up + pointer_leave |
| touchesCancelled | pointer_leave |
The pointer_leave after pointer_up is important for proper button state transitions on touch devices.
Two-finger pinch gestures emit a PINCH event using the pinch center and a per-move scale ratio
clamped to 0.90..1.10.
Debugging
Console Logs
View Rust logs in Xcode’s console or use Console.app with a filter:
subsystem:com.blinc.my_app
Common Issues
“Library not found: -lmy_app”
Run the build script first:
./build-ios.sh
Black screen on simulator
- Ensure you built for the correct simulator target (
aarch64-apple-ios-sim) - Verify the library is in
libs/simulator/ - Check Xcode console for Metal initialization errors
Touch events not working
- Verify
blinc_create_contextsucceeds (check console logs) - Ensure
ios_app_init()is called before creating the context - Check that touch coordinates are in logical points, not pixels
Performance Tips
-
Use release builds for performance testing:
./build-ios.sh release -
Enable LTO in Cargo.toml:
[profile.release] lto = "thin" opt-level = "z" strip = true -
Test on real devices - simulators use software rendering for some operations
-
Profile with Instruments - use Xcode’s Metal debugger for GPU analysis
CLI Reference
The Blinc CLI simplifies creating and building mobile projects.
Creating a Project
New Project
blinc new my-app --template rust
This creates a new Blinc project with:
- Cargo.toml configured for mobile targets
- Platform directories for Android and iOS
- Build scripts for each platform
- Example UI code
Options
blinc new <name> [options]
Options:
--template <type> Project template (rust, swift, kotlin)
--platforms <list> Target platforms (desktop,android,ios)
--no-git Skip git initialization
Building
Build for Android
blinc build android
Options:
blinc build android [options]
Options:
--release Build in release mode
--target <arch> Target architecture (arm64-v8a, armeabi-v7a, x86_64, x86)
--all-targets Build for all architectures
Build for iOS
blinc build ios
Options:
blinc build ios [options]
Options:
--release Build in release mode
--device Build for physical device only
--simulator Build for simulator only
Running
Run on Android
blinc run android
This will:
- Build the native library
- Build the APK with Gradle
- Install on connected device/emulator
- Launch the app
Options:
blinc run android [options]
Options:
--release Run release build
--device <id> Target specific device (from adb devices)
--no-install Build only, don't install
Run on iOS
blinc run ios
This will:
- Build the static library
- Open Xcode project
- Build and run on selected target
Options:
blinc run ios [options]
Options:
--release Run release build
--simulator <name> Target specific simulator
--device Run on physical device
Project Configuration
blinc.toml
The project configuration file:
[project]
name = "my-app"
version = "0.1.0"
template = "rust"
entry = "Cargo.toml"
[targets]
default = "desktop"
supported = ["desktop", "android", "ios"]
[targets.desktop]
enabled = true
command = "cargo run --features desktop"
[targets.android]
enabled = true
platform_dir = "platforms/android"
[targets.ios]
enabled = true
platform_dir = "platforms/ios"
[build]
blinc_path = "../.." # Path to Blinc framework
Configuration Options
| Key | Description | Default |
|---|---|---|
project.name | Project name | Required |
project.version | Version string | “0.1.0” |
project.template | Template type | “rust” |
targets.default | Default build target | “desktop” |
targets.supported | List of supported platforms | [“desktop”] |
build.blinc_path | Path to Blinc framework | “../..” |
Cleaning
# Clean all build artifacts
blinc clean
# Clean specific platform
blinc clean android
blinc clean ios
Checking Configuration
# Validate project configuration
blinc check
# Check specific platform setup
blinc check android
blinc check ios
This verifies:
- Required tools are installed
- Environment variables are set
- Project configuration is valid
Elements & Layout
Blinc provides a set of core elements that can be composed to build any UI. All elements implement the ElementBuilder trait and use a fluent builder pattern.
Core Elements
Div - The Universal Container
div() is the primary building block. It’s a flexible container that supports:
- Flexbox layout
- Background colors and materials
- Borders and shadows
- Event handling
- Child elements
#![allow(unused)]
fn main() {
div()
.w(200.0)
.h(100.0)
.bg(Color::rgba(0.2, 0.2, 0.3, 1.0))
.rounded(8.0)
.flex_center()
.child(text("Hello"))
}
Text - Typography
text(content) renders text with customizable typography:
#![allow(unused)]
fn main() {
text("Hello, World!")
.size(24.0)
.weight(FontWeight::Bold)
.color(Color::WHITE)
.family("Inter")
}
Text Properties:
.size(px)- Font size in pixels.weight(FontWeight)- Bold, SemiBold, Medium, Regular, Light.color(Color)- Text color.family(name)- Font family.italic()- Italic style.underline()- Underline decoration.line_height(multiplier)- Line height as multiplier of font size.letter_spacing(px)- Space between characters.align(TextAlign)- Left, Center, Right, Justify
Typography Helpers:
#![allow(unused)]
fn main() {
h1("Heading 1") // 32px bold
h2("Heading 2") // 28px bold
h3("Heading 3") // 24px bold
h4("Heading 4") // 20px semibold
h5("Heading 5") // 16px semibold
h6("Heading 6") // 14px semibold
p("Paragraph") // 14px regular
caption("Caption") // 12px regular
label("Label") // 14px medium
muted("Muted text") // Reduced opacity
b("Bold text") // Bold weight
small("Small") // 12px
}
Stack - Overlapping Layers
stack() positions children on top of each other, useful for overlays and layered designs:
#![allow(unused)]
fn main() {
stack()
.w(200.0)
.h(200.0)
// Background layer
.child(
div().w_full().h_full().bg(Color::BLUE)
)
// Foreground layer
.child(
div()
.absolute()
.right(10.0)
.bottom(10.0)
.w(50.0)
.h(50.0)
.bg(Color::RED)
)
}
Canvas - Custom Drawing
canvas(render_fn) provides direct GPU drawing access:
#![allow(unused)]
fn main() {
canvas(|ctx: &mut dyn DrawContext, bounds| {
ctx.fill_rect(
Rect::new(0.0, 0.0, bounds.width, bounds.height),
CornerRadius::uniform(8.0),
Brush::Solid(Color::RED),
);
})
.w(200.0)
.h(100.0)
}
See Canvas Drawing for more details.
Image & SVG
#![allow(unused)]
fn main() {
// Raster images
image("path/to/image.png")
.w(200.0)
.h(150.0)
.cover() // Object-fit: cover
// SVG with tint
svg("path/to/icon.svg")
.w(24.0)
.h(24.0)
.tint(Color::WHITE)
}
See Images & SVG for more details.
Layout System
Blinc uses Flexbox for layout, powered by Taffy.
Sizing
#![allow(unused)]
fn main() {
div()
.w(200.0) // Fixed width in pixels
.h(100.0) // Fixed height in pixels
.w_full() // 100% width
.h_full() // 100% height
.w_auto() // Auto width (content-based)
.h_auto() // Auto height (content-based)
.w_fit() // Shrink-wrap to content
.size(200.0, 100.0) // Set both dimensions
.square(100.0) // Square element
.min_w(50.0) // Minimum width
.max_w(500.0) // Maximum width
.min_h(50.0) // Minimum height
.max_h(300.0) // Maximum height
.aspect_ratio(16.0 / 9.0) // Maintain aspect ratio
}
Flex Container
#![allow(unused)]
fn main() {
div()
.flex() // Enable flexbox
.flex_row() // Horizontal layout (default)
.flex_col() // Vertical layout
.flex_row_reverse() // Right to left
.flex_col_reverse() // Bottom to top
.flex_wrap() // Wrap children
}
Flex Items
#![allow(unused)]
fn main() {
div()
.flex_grow() // Grow to fill space (flex-grow: 1)
.flex_shrink() // Allow shrinking (flex-shrink: 1)
.flex_shrink_0() // Don't shrink (flex-shrink: 0)
.flex_1() // flex: 1 1 0% (grow and shrink)
.flex_auto() // flex: 1 1 auto
}
Alignment
Align Items (cross-axis alignment):
#![allow(unused)]
fn main() {
div()
.items_start() // Align to start
.items_center() // Center alignment
.items_end() // Align to end
.items_stretch() // Stretch to fill
.items_baseline() // Align baselines
}
Justify Content (main-axis distribution):
#![allow(unused)]
fn main() {
div()
.justify_start() // Pack at start
.justify_center() // Center items
.justify_end() // Pack at end
.justify_between() // Space between items
.justify_around() // Space around items
.justify_evenly() // Equal spacing
}
Convenience Methods:
#![allow(unused)]
fn main() {
div().flex_center() // Center both axes
div().flex_col().justify_center().items_center() // Same as above
}
Gap (Spacing Between Children)
#![allow(unused)]
fn main() {
div()
.gap(16.0) // Gap in pixels
.gap_x(8.0) // Horizontal gap only
.gap_y(12.0) // Vertical gap only
}
Padding
Padding uses a 4px unit system (like Tailwind CSS):
#![allow(unused)]
fn main() {
div()
.p(4.0) // 16px padding all sides (4 * 4px)
.px(2.0) // 8px horizontal padding
.py(3.0) // 12px vertical padding
.pt(1.0) // 4px top padding
.pr(2.0) // 8px right padding
.pb(3.0) // 12px bottom padding
.pl(4.0) // 16px left padding
.p_px(20.0) // 20px (exact pixels, not units)
}
Margin
Same unit system as padding:
#![allow(unused)]
fn main() {
div()
.m(4.0) // 16px margin all sides
.mx(2.0) // 8px horizontal margin
.my(3.0) // 12px vertical margin
.mt(1.0) // 4px top margin
.mr(2.0) // 8px right margin
.mb(3.0) // 12px bottom margin
.ml(4.0) // 16px left margin
.mx_auto() // Auto horizontal margins (centering)
}
Positioning
#![allow(unused)]
fn main() {
div()
.relative() // Position relative
.absolute() // Position absolute
.inset(10.0) // 10px from all edges
.top(20.0) // 20px from top
.right(20.0) // 20px from right
.bottom(20.0) // 20px from bottom
.left(20.0) // 20px from left
}
Overflow
#![allow(unused)]
fn main() {
div()
.overflow_clip() // Clip overflowing content
.overflow_visible() // Allow overflow
.overflow_scroll() // Enable scrolling
}
Common Layout Patterns
Centered Content
#![allow(unused)]
fn main() {
div()
.w_full()
.h_full()
.flex_center()
.child(content)
}
Sidebar Layout
#![allow(unused)]
fn main() {
div()
.w_full()
.h_full()
.flex_row()
.child(
div().w(250.0).h_full() // Sidebar
)
.child(
div().flex_1().h_full() // Main content
)
}
Card Grid
#![allow(unused)]
fn main() {
div()
.w_full()
.flex_row()
.flex_wrap()
.gap(16.0)
.child(card().w(300.0))
.child(card().w(300.0))
.child(card().w(300.0))
}
Header/Content/Footer
#![allow(unused)]
fn main() {
div()
.w_full()
.h_full()
.flex_col()
.child(
div().h(60.0).w_full() // Header
)
.child(
div().flex_1().w_full() // Content (fills remaining)
)
.child(
div().h(40.0).w_full() // Footer
)
}
Horizontal Navigation
#![allow(unused)]
fn main() {
div()
.w_full()
.h(60.0)
.flex_row()
.items_center()
.justify_between()
.px(4.0)
.child(logo())
.child(
div()
.flex_row()
.gap(24.0)
.child(nav_item("Home"))
.child(nav_item("About"))
.child(nav_item("Contact"))
)
}
The .child() Pattern
Add children with .child(). For multiple children of the same type, use iterators:
#![allow(unused)]
fn main() {
// Single child
div().child(text("Hello"))
// Multiple children
div()
.child(text("First"))
.child(text("Second"))
.child(text("Third"))
// From iterator
let items = vec!["Apple", "Banana", "Cherry"];
div().child(
items.into_iter().map(|item| text(item))
)
}
ElementBuilder Trait
All elements implement ElementBuilder:
#![allow(unused)]
fn main() {
pub trait ElementBuilder {
fn build(self, tree: &mut LayoutTree) -> LayoutNodeId;
}
}
This allows composing any element type:
#![allow(unused)]
fn main() {
fn my_component() -> impl ElementBuilder {
div().child(text("Hello"))
}
// Use it
div().child(my_component())
}
Styling & Materials
Blinc provides comprehensive styling options from simple colors to advanced GPU-accelerated material effects.
Colors
Basic Colors
Colors are RGBA with values from 0.0 to 1.0:
#![allow(unused)]
fn main() {
// RGBA constructor
Color::rgba(0.2, 0.4, 0.8, 1.0) // Blue, fully opaque
Color::rgba(1.0, 0.0, 0.0, 0.5) // Red, 50% transparent
// From array (common pattern)
Color::from([0.2, 0.4, 0.8, 1.0])
// Predefined colors
Color::WHITE
Color::BLACK
Color::RED
Color::GREEN
Color::BLUE
Color::TRANSPARENT
}
Background Colors
#![allow(unused)]
fn main() {
div()
.bg(Color::rgba(0.1, 0.1, 0.15, 1.0))
// From array shorthand
div().bg([0.1, 0.1, 0.15, 1.0])
}
Gradients
For gradients, use the .background() method with a Brush:
#![allow(unused)]
fn main() {
use blinc_core::{Brush, Gradient, GradientStop, Point};
div()
.w(200.0)
.h(100.0)
.background(Brush::Gradient(Gradient::linear_with_stops(
Point::new(0.0, 0.0), // Start point
Point::new(200.0, 0.0), // End point
vec![
GradientStop::new(0.0, Color::rgba(0.9, 0.2, 0.5, 1.0)),
GradientStop::new(0.5, Color::rgba(0.9, 0.5, 0.2, 1.0)),
GradientStop::new(1.0, Color::rgba(0.2, 0.8, 0.6, 1.0)),
],
)))
}
Borders & Corners
Corner Radius
#![allow(unused)]
fn main() {
div()
.rounded(8.0) // Uniform radius
.rounded_full() // Pill shape (50% of smallest dimension)
.rounded_corners(
16.0, // Top-left
16.0, // Top-right
8.0, // Bottom-right
8.0, // Bottom-left
)
}
Shadows
Preset Shadows
#![allow(unused)]
fn main() {
div()
.shadow_sm() // Small shadow
.shadow_md() // Medium shadow
.shadow_lg() // Large shadow
.shadow_xl() // Extra large shadow
}
Custom Shadows
#![allow(unused)]
fn main() {
div().shadow_params(
2.0, // Offset X
4.0, // Offset Y
12.0, // Blur radius
Color::rgba(0.0, 0.0, 0.0, 0.3)
)
}
Opacity
#![allow(unused)]
fn main() {
div()
.opacity(0.5) // 50% opacity
.opaque() // opacity: 1.0
.translucent() // opacity: 0.5
.invisible() // opacity: 0.0
}
Transforms
Apply 2D transforms to any element:
#![allow(unused)]
fn main() {
div()
.translate(10.0, 20.0) // Move by (x, y)
.scale(1.5) // Uniform scale
.scale_xy(1.5, 0.8) // Non-uniform scale
.rotate(45.0_f32.to_radians()) // Rotate (radians)
.rotate_deg(45.0) // Rotate (degrees)
}
For combined transforms:
#![allow(unused)]
fn main() {
use blinc_core::Transform;
div().transform(
Transform::translate(100.0, 50.0)
.then_scale(1.2, 1.2)
.then_rotate(0.1)
)
}
Materials
Blinc includes GPU-accelerated material effects for modern, polished UIs.
Glass Material
Creates a frosted glass effect with background blur:
#![allow(unused)]
fn main() {
// Quick glass
div().glass()
// Customized glass
use blinc_core::GlassMaterial;
div().material(Material::Glass(
GlassMaterial::new()
.blur(20.0) // Blur intensity (0-50)
.tint(Color::rgba(1.0, 1.0, 1.0, 0.1))
.saturation(1.2) // Color saturation
.brightness(1.0) // Brightness adjustment
.noise(0.03) // Frosted texture
.border(0.8) // Border highlight intensity
))
}
Glass Presets:
#![allow(unused)]
fn main() {
GlassMaterial::ultra_thin() // Very subtle
GlassMaterial::thin() // Light blur
GlassMaterial::regular() // Standard (default)
GlassMaterial::thick() // Heavy blur
GlassMaterial::frosted() // Frosted window style
GlassMaterial::card() // Card-like appearance
}
Metallic Material
Creates reflective metallic surfaces:
#![allow(unused)]
fn main() {
use blinc_core::MetallicMaterial;
div().material(Material::Metallic(
MetallicMaterial::new()
.color(Color::WHITE)
.roughness(0.3) // 0 = mirror, 1 = matte
.metallic(1.0) // Metal intensity
.reflection(0.5) // Reflection strength
))
}
Metallic Presets:
#![allow(unused)]
fn main() {
MetallicMaterial::chrome() // Polished chrome
MetallicMaterial::brushed() // Brushed metal
MetallicMaterial::gold() // Gold finish
MetallicMaterial::silver() // Silver finish
MetallicMaterial::copper() // Copper finish
}
Quick Material Methods
#![allow(unused)]
fn main() {
div().glass() // Default glass material
div().metallic() // Default metallic material
div().chrome() // Chrome preset
div().gold() // Gold preset
}
Render Layers
Control rendering order with layers:
#![allow(unused)]
fn main() {
use blinc_core::RenderLayer;
div()
.layer(RenderLayer::Background) // Rendered first
.child(background_content())
div()
.layer(RenderLayer::Foreground) // Rendered on top
.child(overlay_content())
}
For glass effects, content behind glass should be on .background() layer:
#![allow(unused)]
fn main() {
stack()
.child(
div().background() // Behind glass
.child(colorful_background())
)
.child(
div().glass() // Glass overlay
.foreground() // On top
.child(content())
)
}
Common Styling Patterns
Card Style
#![allow(unused)]
fn main() {
fn card() -> Div {
div()
.p(16.0)
.rounded(12.0)
.bg(Color::rgba(0.15, 0.15, 0.2, 1.0))
.shadow_md()
}
}
Glass Card
#![allow(unused)]
fn main() {
fn glass_card() -> Div {
div()
.p(16.0)
.rounded(16.0)
.glass()
.shadow_lg()
}
}
Button Styles
#![allow(unused)]
fn main() {
fn primary_button() -> Div {
div()
.px(4.0)
.py(2.0)
.rounded(8.0)
.bg(Color::rgba(0.3, 0.5, 0.9, 1.0))
}
fn secondary_button() -> Div {
div()
.px(4.0)
.py(2.0)
.rounded(8.0)
.bg(Color::rgba(0.2, 0.2, 0.25, 1.0))
}
fn ghost_button() -> Div {
div()
.px(4.0)
.py(2.0)
.rounded(8.0)
.bg(Color::TRANSPARENT)
}
}
Hover Effects with State
Use stateful::<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 --example rich_text_demo --features windowed
Scroll Containers
Blinc provides scroll containers with WebKit-style momentum scrolling and bounce physics.
Basic Scroll
#![allow(unused)]
fn main() {
use blinc_layout::widgets::scroll::scroll;
fn scrollable_content() -> impl ElementBuilder {
scroll()
.h(400.0)
.child(
div()
.flex_col()
.gap(8.0)
.child(/* ... long content ... */)
)
}
}
Scroll Without Bounce
#![allow(unused)]
fn main() {
use blinc_layout::widgets::scroll::scroll_no_bounce;
scroll_no_bounce()
.h(400.0)
.child(content)
}
Scroll Configuration
#![allow(unused)]
fn main() {
use blinc_layout::widgets::scroll::{Scroll, ScrollConfig, ScrollDirection};
use blinc_animation::SpringConfig;
Scroll::with_config(ScrollConfig {
bounce_enabled: true,
bounce_spring: SpringConfig::wobbly(),
deceleration: 1500.0,
velocity_threshold: 10.0,
max_overscroll: 0.3, // 30% of viewport
direction: ScrollDirection::Vertical,
})
.h(400.0)
.child(content)
}
Configuration Presets
#![allow(unused)]
fn main() {
ScrollConfig::default() // Standard bounce
ScrollConfig::no_bounce() // No bounce physics
ScrollConfig::stiff_bounce() // Tight, minimal bounce
ScrollConfig::gentle_bounce() // Soft, more bounce
}
Scroll Directions
#![allow(unused)]
fn main() {
// Vertical only (default)
Scroll::with_config(ScrollConfig {
direction: ScrollDirection::Vertical,
..Default::default()
})
// Horizontal only
Scroll::with_config(ScrollConfig {
direction: ScrollDirection::Horizontal,
..Default::default()
})
// Both directions
Scroll::with_config(ScrollConfig {
direction: ScrollDirection::Both,
..Default::default()
})
}
Scroll States
Scroll containers use ScrollState for physics-driven behavior:
#![allow(unused)]
fn main() {
ScrollState::Idle // Not scrolling
ScrollState::Scrolling // User is dragging
ScrollState::Decelerating // Momentum after release
ScrollState::Bouncing // Edge bounce animation
}
Example: Scrollable List
#![allow(unused)]
fn main() {
fn message_list() -> impl ElementBuilder {
scroll()
.h(500.0)
.w_full()
.child(
div()
.flex_col()
.gap(8.0)
.p(16.0)
.child(
(0..50).map(|i| {
div()
.p(12.0)
.rounded(8.0)
.bg(Color::rgba(0.15, 0.15, 0.2, 1.0))
.child(
text(&format!("Message {}", i + 1))
.color(Color::WHITE)
)
})
)
)
}
}
Example: Horizontal Gallery
#![allow(unused)]
fn main() {
fn image_gallery() -> impl ElementBuilder {
Scroll::with_config(ScrollConfig {
direction: ScrollDirection::Horizontal,
..Default::default()
})
.h(200.0)
.w_full()
.child(
div()
.flex_row()
.gap(16.0)
.p(16.0)
.child(
(0..10).map(|i| {
div()
.w(150.0)
.h(150.0)
.rounded(12.0)
.bg(Color::rgba(0.2, 0.3, 0.5, 1.0))
.flex_center()
.child(text(&format!("{}", i + 1)).size(24.0).color(Color::WHITE))
})
)
)
}
}
Nested Scrolling
Scroll containers handle nested scrolling automatically. Inner scrolls consume events when they can scroll; outer scrolls take over at boundaries.
#![allow(unused)]
fn main() {
fn nested_scroll_example() -> impl ElementBuilder {
// Outer vertical scroll
scroll()
.h(600.0)
.child(
div()
.flex_col()
.gap(16.0)
.child(text("Section 1").size(24.0))
// Inner horizontal scroll
.child(
Scroll::with_config(ScrollConfig {
direction: ScrollDirection::Horizontal,
..Default::default()
})
.h(120.0)
.child(horizontal_items())
)
.child(text("Section 2").size(24.0))
.child(more_content())
)
}
}
Physics Parameters
| Parameter | Default | Description |
|---|---|---|
deceleration | 1500.0 | How quickly momentum decays (higher = faster stop) |
velocity_threshold | 10.0 | Minimum velocity to continue momentum |
max_overscroll | 0.3 | Maximum overscroll as fraction of viewport |
bounce_spring | wobbly | Spring config for bounce animation |
Programmatic Scroll Control
Blinc provides a powerful selector API for programmatic scroll control through ScrollRef. This allows you to scroll to specific elements, positions, or the top/bottom of content.
Creating a ScrollRef
Use ctx.use_scroll_ref() to create a persistent scroll reference:
#![allow(unused)]
fn main() {
use blinc_layout::selector::{ScrollRef, ScrollOptions, ScrollBehavior, ScrollBlock};
fn my_component(ctx: &WindowedContext) -> impl ElementBuilder {
// Create a ScrollRef - persists across rebuilds
let scroll_ref = ctx.use_scroll_ref("my_scroll");
scroll()
.bind(&scroll_ref) // Bind the ref to this scroll container
.child(content)
}
}
Element IDs
Assign IDs to elements you want to scroll to:
#![allow(unused)]
fn main() {
fn card_list() -> impl ElementBuilder {
div()
.flex_col()
.children(
(0..10).map(|i| {
div()
.id(format!("card-{}", i)) // Assign unique ID
.child(text(&format!("Card {}", i)))
})
)
}
}
Scrolling to Elements
Use scroll_to() or scroll_to_with_options() to scroll to an element by ID:
#![allow(unused)]
fn main() {
// Simple scroll to element
scroll_ref.scroll_to("card-5");
// Scroll with options
scroll_ref.scroll_to_with_options(
"card-5",
ScrollOptions {
behavior: ScrollBehavior::Smooth, // Animate the scroll
block: ScrollBlock::Center, // Center element in viewport
..Default::default()
},
);
}
ScrollOptions
Configure how the scroll behaves:
#![allow(unused)]
fn main() {
ScrollOptions {
behavior: ScrollBehavior::Smooth, // or ScrollBehavior::Auto (instant)
block: ScrollBlock::Center, // Vertical alignment
inline: ScrollInline::Nearest, // Horizontal alignment
}
}
| Block/Inline Value | Description |
|---|---|
Start | Align to top/left of viewport |
Center | Align to center of viewport |
End | Align to bottom/right of viewport |
Nearest | Scroll minimum distance to make visible (default) |
Other Scroll Operations
#![allow(unused)]
fn main() {
// Scroll to top/bottom
scroll_ref.scroll_to_top();
scroll_ref.scroll_to_bottom();
// With smooth animation
scroll_ref.scroll_to_bottom_with_behavior(ScrollBehavior::Smooth);
// Scroll by relative amount
scroll_ref.scroll_by(0.0, 100.0); // Scroll down 100px
// Set absolute offset
scroll_ref.set_scroll_offset(0.0, 500.0);
}
Querying Scroll State
#![allow(unused)]
fn main() {
// Current offset
let (x, y) = scroll_ref.offset();
let y = scroll_ref.scroll_y();
// Content and viewport sizes
let content_size = scroll_ref.content_size();
let viewport_size = scroll_ref.viewport_size();
// Position checks
if scroll_ref.is_at_top() { /* ... */ }
if scroll_ref.is_at_bottom() { /* ... */ }
// Scroll progress (0.0 = top, 1.0 = bottom)
let progress = scroll_ref.scroll_progress();
}
Example: Carousel with Dot Navigation
Here’s a complete example of a horizontal carousel with clickable navigation dots:
#![allow(unused)]
fn main() {
use blinc_app::prelude::*;
use blinc_layout::selector::{ScrollBehavior, ScrollBlock, ScrollOptions, ScrollRef};
use blinc_layout::units::px; // Semantic unit for raw pixels
fn carousel(ctx: &WindowedContext) -> impl ElementBuilder {
let scroll_ref = ctx.use_scroll_ref("carousel_scroll");
let current_index = ctx.use_state_keyed("current_index", || 0usize);
div()
.flex_col()
.items_center()
.gap(16.0)
// Horizontal scroll carousel
.child(
scroll()
.bind(&scroll_ref)
.direction(ScrollDirection::Horizontal)
.w(400.0)
.h(300.0)
.child(
div()
.flex_row()
.gap(20.0)
.padding_x(px(60.0)) // Padding to center first/last cards
.children(
(0..5).map(|i| {
div()
.id(format!("card-{}", i)) // Element ID
.w(280.0)
.h(280.0)
.bg(Color::rgba(0.2, 0.3, 0.5, 1.0))
.rounded(16.0)
.child(text(&format!("Card {}", i + 1)))
})
),
),
)
// Navigation dots
.child(build_dots(ctx, &scroll_ref, ¤t_index))
}
fn build_dots(
ctx: &WindowedContext,
scroll_ref: &ScrollRef,
current_index: &State<usize>,
) -> impl ElementBuilder {
div()
.flex_row()
.gap(12.0)
.children(
(0..5).map(|i| {
let scroll_ref = scroll_ref.clone();
let current_index = current_index.clone();
div()
.w(12.0)
.h(12.0)
.rounded(6.0)
.bg(if i == current_index.get() {
Color::rgba(0.4, 0.6, 1.0, 1.0)
} else {
Color::rgba(0.3, 0.3, 0.4, 1.0)
})
.on_click(move |_| {
current_index.set(i);
scroll_ref.scroll_to_with_options(
&format!("card-{}", i),
ScrollOptions {
behavior: ScrollBehavior::Smooth,
block: ScrollBlock::Center,
..Default::default()
},
);
})
})
)
}
}
Best Practices
-
Set explicit height - Scroll containers need a bounded height to work.
-
Use overflow_clip on parent - Ensure parent clips overflowing content.
-
Prefer vertical for long content - Horizontal scrolling is less intuitive for lists.
-
Consider no-bounce for forms - Disable bounce for content that needs precise positioning.
-
Test nested scrolling - Verify inner/outer scroll interactions work as expected.
-
Use meaningful element IDs - Choose descriptive IDs like
"message-123"or"section-intro"for elements you need to scroll to. -
Prefer
ctx.use_scroll_ref()- Always use the context method rather thanScrollRef::new()for proper reactive integration.
Canvas Drawing
The canvas() element provides direct GPU drawing access for custom graphics, charts, and procedural content.
Basic Usage
#![allow(unused)]
fn main() {
use blinc_core::{DrawContext, Rect, Brush, Color, CornerRadius};
canvas(|ctx: &mut dyn DrawContext, bounds| {
// bounds contains the canvas size
ctx.fill_rect(
Rect::new(0.0, 0.0, bounds.width, bounds.height),
CornerRadius::uniform(8.0),
Brush::Solid(Color::RED),
);
})
.w(200.0)
.h(100.0)
}
Drawing Primitives
Filled Rectangles
#![allow(unused)]
fn main() {
ctx.fill_rect(
Rect::new(x, y, width, height),
CornerRadius::uniform(8.0), // Corner radius
Brush::Solid(Color::BLUE),
);
// No corner radius
ctx.fill_rect(
Rect::new(10.0, 10.0, 100.0, 50.0),
CornerRadius::default(),
Brush::Solid(Color::GREEN),
);
}
Stroked Rectangles
#![allow(unused)]
fn main() {
ctx.stroke_rect(
Rect::new(x, y, width, height),
CornerRadius::uniform(4.0),
2.0, // Stroke width
Brush::Solid(Color::WHITE),
);
}
Circles
#![allow(unused)]
fn main() {
// Filled circle
ctx.fill_circle(
Point::new(cx, cy), // Center
radius,
Brush::Solid(Color::BLUE),
);
// Stroked circle
ctx.stroke_circle(
Point::new(cx, cy),
radius,
2.0, // Stroke width
Brush::Solid(Color::WHITE),
);
}
Text
#![allow(unused)]
fn main() {
use blinc_core::TextStyle;
ctx.draw_text(
"Hello, Canvas!",
Point::new(x, y),
&TextStyle::new(16.0).with_color(Color::WHITE),
);
}
Gradients
#![allow(unused)]
fn main() {
use blinc_core::{Gradient, GradientStop, Point};
// Linear gradient
let gradient = Brush::Gradient(Gradient::linear(
Point::new(0.0, 0.0), // Start
Point::new(200.0, 0.0), // End
Color::rgba(0.9, 0.2, 0.5, 1.0),
Color::rgba(0.2, 0.8, 0.6, 1.0),
));
ctx.fill_rect(
Rect::new(0.0, 0.0, 200.0, 100.0),
CornerRadius::default(),
gradient,
);
// Multi-stop gradient
let gradient = Brush::Gradient(Gradient::linear_with_stops(
Point::new(0.0, 0.0),
Point::new(200.0, 0.0),
vec![
GradientStop::new(0.0, Color::RED),
GradientStop::new(0.5, Color::YELLOW),
GradientStop::new(1.0, Color::GREEN),
],
));
}
Transforms
#![allow(unused)]
fn main() {
use blinc_core::Transform;
// Push transform
ctx.push_transform(Transform::translate(50.0, 50.0));
// Draw in transformed space
ctx.fill_rect(/* ... */);
// Pop transform
ctx.pop_transform();
// Rotation
ctx.push_transform(Transform::rotate(angle_radians));
// ... draw ...
ctx.pop_transform();
// Scale
ctx.push_transform(Transform::scale(2.0, 2.0));
// ... draw ...
ctx.pop_transform();
}
Clipping
#![allow(unused)]
fn main() {
// Push clip region
ctx.push_clip(Rect::new(10.0, 10.0, 100.0, 100.0));
// Only content within clip region is visible
ctx.fill_rect(/* ... */);
// Pop clip
ctx.pop_clip();
}
Example: Animated Spinner
#![allow(unused)]
fn main() {
use std::f32::consts::PI;
fn spinner(ctx: &WindowedContext) -> impl ElementBuilder {
let timeline = ctx.use_animated_timeline();
let entry_id = timeline.lock().unwrap().configure(|t| {
let id = t.add(0, 1000, 0.0, 360.0);
t.set_loop(-1);
t.start();
id
});
let render_timeline = Arc::clone(&timeline);
canvas(move |draw_ctx, bounds| {
let angle_deg = render_timeline.lock().unwrap().get(entry_id).unwrap_or(0.0);
let angle_rad = angle_deg * PI / 180.0;
let cx = bounds.width / 2.0;
let cy = bounds.height / 2.0;
let radius = 30.0;
// Draw spinning segments
for i in 0..8 {
let segment_angle = angle_rad + (i as f32 * PI / 4.0);
let alpha = 1.0 - (i as f32 * 0.1);
let x = cx + segment_angle.cos() * radius;
let y = cy + segment_angle.sin() * radius;
draw_ctx.fill_circle(
Point::new(x, y),
4.0,
Brush::Solid(Color::rgba(0.4, 0.6, 1.0, alpha)),
);
}
})
.w(80.0)
.h(80.0)
}
}
Example: Progress Ring
#![allow(unused)]
fn main() {
fn progress_ring(progress: f32) -> impl ElementBuilder {
canvas(move |ctx, bounds| {
let cx = bounds.width / 2.0;
let cy = bounds.height / 2.0;
let radius = bounds.width.min(bounds.height) / 2.0 - 4.0;
// Background ring
ctx.stroke_circle(
Point::new(cx, cy),
radius,
4.0,
Brush::Solid(Color::rgba(0.2, 0.2, 0.25, 1.0)),
);
// Progress arc (simplified - actual arc drawing would need path API)
// For now, draw segments
let segments = 32;
let filled = (segments as f32 * progress) as i32;
for i in 0..filled {
let angle = (i as f32 / segments as f32) * 2.0 * PI - PI / 2.0;
let x = cx + angle.cos() * radius;
let y = cy + angle.sin() * radius;
ctx.fill_circle(
Point::new(x, y),
3.0,
Brush::Solid(Color::rgba(0.4, 0.6, 1.0, 1.0)),
);
}
// Center text
ctx.draw_text(
&format!("{}%", (progress * 100.0) as i32),
Point::new(cx - 15.0, cy + 6.0),
&TextStyle::new(16.0).with_color(Color::WHITE),
);
})
.w(80.0)
.h(80.0)
}
}
Example: Color Palette
#![allow(unused)]
fn main() {
fn color_palette() -> impl ElementBuilder {
canvas(|ctx, bounds| {
let cols = 8;
let rows = 3;
let cell_w = bounds.width / cols as f32;
let cell_h = bounds.height / rows as f32;
for row in 0..rows {
for col in 0..cols {
let hue = col as f32 / cols as f32;
let sat = 1.0 - (row as f32 * 0.25);
let color = hsv_to_rgb(hue, sat, 0.9);
ctx.fill_rect(
Rect::new(
col as f32 * cell_w,
row as f32 * cell_h,
cell_w - 2.0,
cell_h - 2.0,
),
CornerRadius::uniform(4.0),
Brush::Solid(color),
);
}
}
})
.w(240.0)
.h(90.0)
}
}
Best Practices
-
Set explicit size - Canvas needs width and height to render.
-
Use bounds parameter - Draw relative to
bounds.widthandbounds.height. -
Clone Arcs for closures - Animation values need
Arc::clone()before the render closure. -
Push/pop transforms - Always pop what you push to avoid state leaks.
-
Prefer elements when possible - Use
div(),text()for standard UI; canvas for custom graphics.
Images & SVG
Blinc supports raster images and SVG graphics with flexible sizing and styling options.
Images
Basic Image
#![allow(unused)]
fn main() {
use blinc_layout::image::image;
image("path/to/photo.png")
.w(200.0)
.h(150.0)
}
Image from URL
#![allow(unused)]
fn main() {
image("https://example.com/image.jpg")
.w(300.0)
.h(200.0)
}
Object Fit
Control how the image fills its container:
#![allow(unused)]
fn main() {
image(src)
.w(200.0)
.h(200.0)
.cover() // Fill container, crop if needed (default)
image(src)
.contain() // Fit entirely, may letterbox
image(src)
.fill() // Stretch to fill exactly
image(src)
.scale_down() // Scale down only if larger
image(src)
.no_scale() // No scaling, original size
}
Object Position
Control alignment within the container:
#![allow(unused)]
fn main() {
image(src)
.cover()
.center() // Center (default)
image(src)
.cover()
.top_left()
image(src)
.cover()
.top_center()
image(src)
.cover()
.bottom_right()
// Custom position (0.0 to 1.0)
image(src)
.cover()
.position_xy(0.25, 0.75)
}
Image Filters
#![allow(unused)]
fn main() {
image(src)
.w(200.0)
.h(200.0)
.grayscale(0.5) // 0.0 = color, 1.0 = grayscale
.sepia(0.3) // Sepia tone
.brightness(1.2) // > 1.0 brighter, < 1.0 darker
.contrast(1.1) // > 1.0 more contrast
.saturate(0.8) // < 1.0 less saturated
.hue_rotate(45.0) // Rotate hue (degrees)
.invert(0.2) // Color inversion
.blur(2.0) // Blur radius
}
Lazy Loading
For content-heavy applications with many images (galleries, feeds, chat apps), lazy loading defers image loading until the image is visible in the viewport. This reduces initial memory usage and load time.
#![allow(unused)]
fn main() {
use blinc_layout::prelude::*;
use std::time::Duration;
// Basic lazy loading
img("large-photo.jpg")
.lazy()
.w(300.0)
.h(200.0)
// With placeholder color
img("photo.jpg")
.lazy()
.placeholder_color(Color::rgba(0.2, 0.2, 0.2, 1.0))
.w(300.0)
.h(200.0)
// With gradient placeholder using Brush
img("photo.jpg")
.lazy()
.placeholder_brush(Brush::Gradient(Gradient::linear(
Point::new(0.0, 0.0),
Point::new(1.0, 1.0),
Color::rgba(0.4, 0.6, 1.0, 1.0),
Color::rgba(0.6, 0.4, 1.0, 1.0),
)))
.w(300.0)
.h(200.0)
// With thumbnail placeholder
img("large-photo.jpg")
.lazy()
.placeholder_image("thumbnail.jpg")
.fade_in(Duration::from_millis(300))
.w(300.0)
.h(200.0)
// Skeleton loading animation
img("photo.jpg")
.lazy()
.skeleton()
.fade_in(Duration::from_millis(250))
.w(300.0)
.h(200.0)
// Disable fade animation
img("photo.jpg")
.lazy()
.no_fade()
.w(300.0)
.h(200.0)
}
Loading Strategies
| Strategy | Description |
|---|---|
Eager (default) | Load immediately when element is created |
Lazy | Load only when visible in viewport |
Placeholder Types
| Placeholder | Description |
|---|---|
None | No placeholder (blank until loaded) |
Color(color) | Solid color background |
Brush(brush) | Any brush (gradient, glass effect, etc.) |
Image(url) | Another image (e.g., low-res thumbnail, blur hash) |
Skeleton | Shimmer loading animation |
Emoji Images
Render emoji as images at arbitrary sizes using the system emoji font. Emoji images are automatically lazy-loaded for memory efficiency.
#![allow(unused)]
fn main() {
use blinc_layout::image::{emoji, emoji_sized};
// Default size (64px)
emoji("😀")
// Custom size
emoji_sized("🚀", 128.0)
// In a layout
div()
.flex_row()
.gap(8.0)
.child(emoji_sized("👍", 32.0))
.child(emoji_sized("🎉", 32.0))
.child(emoji_sized("✨", 32.0))
}
Emoji images use the system color emoji font (Apple Color Emoji on macOS, Segoe UI Emoji on Windows, Noto Color Emoji on Linux).
SVG
Basic SVG
#![allow(unused)]
fn main() {
use blinc_layout::svg::svg;
svg("icons/menu.svg")
.w(24.0)
.h(24.0)
}
SVG with Tint
Apply a color tint to monochrome SVGs:
#![allow(unused)]
fn main() {
svg("icons/settings.svg")
.w(24.0)
.h(24.0)
.tint(Color::WHITE)
svg("icons/error.svg")
.w(20.0)
.h(20.0)
.tint(Color::rgba(0.9, 0.3, 0.3, 1.0))
}
SVG Sizing
#![allow(unused)]
fn main() {
// Fixed size
svg(src).w(32.0).h(32.0)
// Square shorthand
svg(src).square(24.0)
// Aspect ratio preserved
svg(src).w(48.0).h_auto()
}
Common Patterns
Avatar Image
#![allow(unused)]
fn main() {
fn avatar(url: &str, size: f32) -> impl ElementBuilder {
image(url)
.w(size)
.h(size)
.cover()
.rounded_full() // Circular
}
}
Icon Button
#![allow(unused)]
fn main() {
use blinc_layout::stateful::stateful;
fn icon_button(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.
Markdown Rendering
Blinc includes a built-in markdown renderer that converts CommonMark + GFM markdown to native layout elements.
Basic Usage
#![allow(unused)]
fn main() {
use blinc_layout::markdown::markdown;
// Render markdown to a Div
let content = markdown(r#"
Hello World
This is **bold** and *italic* text.
- List item 1
- List item 2
"#);
// Use in your layout
div()
.flex_col()
.child(content)
}
Themes
The renderer supports light and dark themes:
#![allow(unused)]
fn main() {
use blinc_layout::markdown::{markdown, markdown_light, markdown_with_config, MarkdownConfig};
// Dark theme (default) - for dark backgrounds
let dark_content = markdown("# Dark Theme");
// Light theme - for white/light backgrounds
let light_content = markdown_light("# Light Theme");
// Custom configuration
let custom = markdown_with_config("# Custom", MarkdownConfig {
h1_size: 36.0,
body_size: 16.0,
..MarkdownConfig::default()
});
}
Supported Elements
Text Formatting
| Markdown | Result |
|---|---|
**bold** | bold text |
*italic* | italic text |
~~strikethrough~~ | |
`inline code` | inline code |
[link](url) | clickable link |
#![allow(unused)]
fn main() {
markdown(r#"
This is **bold**, *italic*, and ~~strikethrough~~ text.
Here's some `inline code` and a [link](https://example.com).
"#)
}
Headings
#![allow(unused)]
fn main() {
markdown(r#"
Heading 1
# Heading 2
## Heading 3
### Heading 4
#### Heading 5
##### Heading 6
"#)
}
Lists
Unordered lists:
#![allow(unused)]
fn main() {
markdown(r#"
- First item
- Second item
- Nested item
- Another nested
- Third item
"#)
}
Ordered lists:
#![allow(unused)]
fn main() {
markdown(r#"
1. First step
2. Second step
3. Third step
"#)
}
Task lists (GFM extension):
#![allow(unused)]
fn main() {
markdown(r#"
- [x] Completed task
- [ ] Pending task
- [x] Another done
"#)
}
Code Blocks
Fenced code blocks with optional language:
markdown(r#"
```rust
fn main() {
println!("Hello, Blinc!");
}
“#)
Supported languages for syntax highlighting include Rust, Python, JavaScript, TypeScript, and more.
### Blockquotes
```rust
markdown(r#"
> This is a blockquote.
> It can span multiple lines.
>
> And have multiple paragraphs.
"#)
Tables (GFM)
#![allow(unused)]
fn main() {
markdown(r#"
| Header 1 | Header 2 | Header 3 |
|----------|----------|----------|
| Cell 1 | Cell 2 | Cell 3 |
| Cell 4 | Cell 5 | Cell 6 |
"#)
}
Horizontal Rules
#![allow(unused)]
fn main() {
markdown(r#"
Content above
---
Content below
"#)
}
Images
#![allow(unused)]
fn main() {
markdown(r#"


"#)
}
Configuration
Customize the renderer with MarkdownConfig:
#![allow(unused)]
fn main() {
use blinc_layout::markdown::{markdown_with_config, MarkdownConfig};
use blinc_core::Color;
let config = MarkdownConfig {
// Typography sizes
h1_size: 32.0,
h2_size: 28.0,
h3_size: 24.0,
h4_size: 20.0,
h5_size: 18.0,
h6_size: 16.0,
body_size: 16.0,
code_size: 14.0,
// Colors
text_color: Color::WHITE,
heading_color: Color::WHITE,
link_color: Color::rgba(0.4, 0.6, 1.0, 1.0),
code_bg: Color::rgba(0.1, 0.1, 0.12, 1.0),
code_text: Color::rgba(0.9, 0.6, 0.3, 1.0),
// Spacing
paragraph_spacing: 16.0,
heading_margin_top: 24.0,
heading_margin_bottom: 12.0,
// Lists
list_indent: 24.0,
list_item_spacing: 4.0,
..Default::default()
};
let content = markdown_with_config("# Custom Styled", config);
}
Preset Themes
#![allow(unused)]
fn main() {
// Dark theme (default) - white text on dark backgrounds
let dark = MarkdownConfig::default();
// Light theme - dark text on light backgrounds
let light = MarkdownConfig::light();
}
Live Editor Example
A full markdown editor with live preview is available in the examples:
cargo run -p blinc_app --example markdown_demo --features windowed
This demonstrates:
- TextArea for markdown source editing
- Live preview with
markdown_light() - Stateful reactive updates on text change
HTML Entities
The renderer automatically decodes HTML entities in text:
#![allow(unused)]
fn main() {
markdown(r#"
© 2025 — All rights reserved
Temperature: 72°F
Price: €99.99
"#)
}
Common entities: & (&), < (<), > (>), " ("), (non-breaking space), © (©), ™ (™), and many more.
Best Practices
-
Use
markdown_light()for light backgrounds - The default theme assumes dark backgrounds. -
Wrap in scroll for long content - Markdown can produce tall content:
#![allow(unused)] fn main() { scroll() .h(600.0) .direction(ScrollDirection::Vertical) .child(markdown(long_content)) } -
Set container width - Markdown content respects parent width:
#![allow(unused)] fn main() { div() .w(800.0) .child(markdown(content)) } -
Code blocks need height - For syntax highlighting to render properly, ensure the container has adequate height.
-
Images need explicit dimensions - While images will render, they work best when the markdown container has width constraints.
Element Query API
The Element Query API provides programmatic access to elements in the UI tree, enabling imperative operations like scrolling, focusing, reading bounds, and triggering updates.
Overview
#![allow(unused)]
fn main() {
// Query an element by its string ID
let handle = ctx.query("my-element");
// Check if it exists
if handle.exists() {
// Get computed bounds
if let Some(bounds) = handle.bounds() {
println!("Element at ({}, {}) size {}x{}",
bounds.x, bounds.y, bounds.width, bounds.height);
}
// Scroll into view
handle.scroll_into_view();
// Focus the element
handle.focus();
}
}
Assigning Element IDs
To query an element, it must have a string ID assigned via .id():
#![allow(unused)]
fn main() {
div()
.id("sidebar")
.w(250.0)
.h_full()
.child(
div()
.id("nav-item-home")
.child(text("Home"))
)
.child(
div()
.id("nav-item-settings")
.child(text("Settings"))
)
}
IDs should be unique within your UI. Duplicate IDs will cause the last element to win.
ElementHandle API
Creation & Existence
#![allow(unused)]
fn main() {
// Get a handle - works even if element doesn't exist yet
let handle = ctx.query("my-element");
// Check if element exists in the tree
if handle.exists() {
// Element is rendered
}
// Get the string ID
let id = handle.id(); // "my-element"
}
Bounds & Visibility
#![allow(unused)]
fn main() {
// Get computed bounds after layout
if let Some(bounds) = handle.bounds() {
println!("Position: ({}, {})", bounds.x, bounds.y);
println!("Size: {}x{}", bounds.width, bounds.height);
}
// Check if visible in viewport
if handle.is_visible() {
// Element intersects with window viewport
}
}
Navigation
#![allow(unused)]
fn main() {
// Scroll element into view (smooth scroll)
handle.scroll_into_view();
// Focus the element (for inputs, updates EventRouter)
handle.focus();
// Remove focus
handle.blur();
// Check focus state
if handle.is_focused() {
// Element has keyboard focus
}
}
Tree Traversal
#![allow(unused)]
fn main() {
// Get parent element
if let Some(parent) = handle.parent() {
println!("Parent ID: {}", parent.id());
}
// Iterate over ancestors (parent → grandparent → root)
for ancestor in handle.ancestors() {
println!("Ancestor: {}", ancestor.id());
}
}
Triggering Updates
ElementHandle provides three levels of update granularity:
#![allow(unused)]
fn main() {
// 1. Visual-only update (fastest - skips layout)
// Use for: background color, opacity, shadows, transforms
handle.mark_visual_dirty(
RenderProps::default().with_background(Color::RED.into())
);
// 2. Subtree rebuild with new children
// Use for: structural changes where you know the new content
handle.mark_dirty_subtree(
div().child(text("New content"))
);
// 3. Full rebuild (fallback)
// Triggers complete UI rebuild, diffing determines actual changes
handle.mark_dirty();
}
Signal Integration
#![allow(unused)]
fn main() {
// Emit a signal to trigger reactive updates
// Only rebuilds stateful elements that depend on this signal
handle.emit_signal(my_signal_id);
}
On-Ready Callbacks
Register callbacks that fire once after an element’s first layout:
#![allow(unused)]
fn main() {
ctx.query("progress-bar").on_ready(|bounds| {
// Element has been laid out
println!("Progress bar width: {}", bounds.width);
// Start an animation based on computed size
progress_anim.lock().unwrap().set_target(bounds.width * 0.75);
});
}
On-ready callbacks:
- Fire only once per element ID
- Work even if element doesn’t exist yet (callback queued)
- Survive tree rebuilds (tracked by string ID)
Querying in Event Handlers
Inside event handlers, use the global query() function to get an ElementHandle:
#![allow(unused)]
fn main() {
use blinc_layout::prelude::*;
div()
.on_click(|_| {
// query() returns Option<ElementHandle> - None if element doesn't exist
if let Some(handle) = query("my-element") {
handle.scroll_into_view();
handle.focus();
}
})
}
The query() function uses the global BlincContextState internally, so you don’t need to capture any context or registry in your closures.
For simple operations like scroll and focus without needing the full handle:
#![allow(unused)]
fn main() {
use blinc_core::BlincContextState;
div()
.on_click(|_| {
// Direct access for simple operations
if let Some(ctx) = BlincContextState::try_get() {
ctx.scroll_element_into_view("my-element");
ctx.set_focus(Some("my-input"));
}
})
}
Use Cases
Scroll to Element on Action
#![allow(unused)]
fn main() {
fn scrollable_list(ctx: &WindowedContext) -> impl ElementBuilder {
div()
.flex_col()
.child(
div()
.on_click(|_| {
// Use query() to get handle and scroll
if let Some(handle) = query("list-bottom") {
handle.scroll_into_view();
}
})
.child(text("Jump to Bottom"))
)
.child(
scroll()
.h(400.0)
.child(
div()
.flex_col()
.children((0..100).map(|i| {
div()
.id(format!("item-{}", i))
.child(text(format!("Item {}", i)))
}))
.child(
div().id("list-bottom").h(1.0)
)
)
)
}
}
Focus Management
#![allow(unused)]
fn main() {
fn login_form(ctx: &WindowedContext) -> impl ElementBuilder {
div()
.flex_col()
.gap(16.0)
.child(
text_input(ctx.use_state_keyed::<TextInputState>("username"))
.id("username-input")
.placeholder("Username")
)
.child(
text_input(ctx.use_state_keyed::<TextInputState>("password"))
.id("password-input")
.placeholder("Password")
.on_key_down(|evt| {
if evt.key_code == 9 && evt.shift { // Shift+Tab
if let Some(handle) = query("username-input") {
handle.focus();
}
}
})
)
.child(
div()
.on_click(|_| {
// Focus username on form reset
if let Some(handle) = query("username-input") {
handle.focus();
}
})
.child(text("Reset"))
)
}
}
Measure Element After Layout
#![allow(unused)]
fn main() {
fn responsive_card(ctx: &WindowedContext) -> impl ElementBuilder {
let card_width = ctx.use_signal(0.0f32);
// Register callback to measure after layout
ctx.query("adaptive-card").on_ready(move |bounds| {
// on_ready callback has access to bounds directly
println!("Card width: {}", bounds.width);
});
let width = ctx.get(card_width).unwrap_or(0.0);
let columns = if width > 600.0 { 3 } else if width > 400.0 { 2 } else { 1 };
div()
.id("adaptive-card")
.w_full()
.flex_wrap()
.children((0..9).map(|i| {
div()
.w(pct(100.0 / columns as f32))
.child(text(format!("Item {}", i)))
}))
}
}
Efficient Visual Updates
Use mark_visual_dirty for visual-only changes that don’t affect layout:
#![allow(unused)]
fn main() {
fn highlight_on_selection(ctx: &WindowedContext, selected_id: Option<&str>) -> impl ElementBuilder {
let selected = selected_id.map(|s| s.to_string());
div()
.flex_col()
.children(["item-a", "item-b", "item-c"].iter().map(|id| {
let is_selected = selected.as_deref() == Some(*id);
let id_string = id.to_string();
div()
.id(*id)
.p(12.0)
.bg(if is_selected {
Color::rgba(0.2, 0.5, 1.0, 0.3)
} else {
Color::TRANSPARENT
})
.on_click(move |_| {
// Visual-only update - skips layout recomputation
if let Some(handle) = query(&id_string) {
handle.mark_visual_dirty(
RenderProps::default()
.with_background(Color::rgba(0.2, 0.5, 1.0, 0.3).into())
);
}
})
.child(text(*id))
}))
}
}
Carousel with Snap Points
#![allow(unused)]
fn main() {
fn carousel(ctx: &WindowedContext, items: &[String]) -> impl ElementBuilder {
let current_index = ctx.use_signal(0usize);
div()
.flex_col()
.child(
scroll()
.id("carousel-scroll")
.w(300.0)
.h(200.0)
.scroll_x()
.child(
div()
.flex_row()
.children(items.iter().enumerate().map(|(i, item)| {
div()
.id(format!("slide-{}", i))
.w(300.0)
.h(200.0)
.flex_center()
.child(text(item))
}))
)
)
.child(
div()
.flex_row()
.justify_center()
.gap(8.0)
.children((0..items.len()).map(|i| {
div()
.circle(8.0)
.bg(Color::WHITE.with_alpha(0.5))
.on_click(move |_| {
if let Some(handle) = query(&format!("slide-{}", i)) {
handle.scroll_into_view();
}
})
}))
)
}
---
# Performance Considerations
## Update Granularity
Choose the right update method based on what changed:
| Method | When to Use | Layout Cost |
|--------|-------------|-------------|
| `mark_visual_dirty(props)` | Background, opacity, shadow, transform | None (visual only) |
| `mark_dirty_subtree(div)` | Children structure changed | Subtree only |
| `mark_dirty()` | Unknown changes, fallback | Full rebuild |
| `emit_signal(id)` | Signal-based state change | Targeted stateful |
## Avoid Frequent Queries in Render
```rust
// Bad: Query in render function (called every frame)
fn bad_example(ctx: &WindowedContext) -> impl ElementBuilder {
let bounds = ctx.query("my-element").bounds(); // Called every render!
// ...
}
// Good: Query in event handler or on_ready
fn good_example(ctx: &WindowedContext) -> impl ElementBuilder {
div()
.on_click(|_| {
// query() is designed for use in event handlers
if let Some(handle) = query("my-element") {
let bounds = handle.bounds();
// Use bounds...
}
})
}
}
Use on_ready for Post-Layout Measurements
#![allow(unused)]
fn main() {
// The on_ready callback fires once after first layout
ctx.query("my-element").on_ready(|bounds| {
// Safe to use bounds here - layout is complete
setup_animations_based_on_size(bounds);
});
}
Best Practices
-
Assign meaningful IDs - Use descriptive IDs like
"sidebar","submit-button","user-avatar"rather than generic names. -
Prefer declarative state - Use signals and reactive state for most UI updates. Use ElementHandle for imperative operations like scroll-to and focus.
-
Use visual-only updates - When only colors/opacity/shadows change, use
mark_visual_dirty()to skip layout. -
Handle missing elements - Always check
exists()or handleNonefrombounds()when the element might not be rendered. -
Avoid ID collisions - Each ID should be unique. Consider namespacing like
"dialog-submit","sidebar-nav-home". -
Use on_ready for measurements - Don’t assume bounds are available immediately. Use
on_readyfor post-layout operations.
Overlay System
Blinc provides an overlay system for modals, dialogs, toasts, and context menus.
Overview
Overlays render on top of the main UI and handle their own lifecycle. Access the overlay manager through the context:
#![allow(unused)]
fn main() {
ctx.overlay_manager()
}
Modals
Full-screen overlays with backdrop:
#![allow(unused)]
fn main() {
ctx.overlay_manager()
.modal()
.title("Confirm Action")
.content(|| {
div()
.flex_col()
.gap(16.0)
.child(text("Are you sure you want to proceed?"))
})
.show();
}
Modal with Actions
#![allow(unused)]
fn main() {
ctx.overlay_manager()
.modal()
.title("Delete Item")
.content(|| {
text("This action cannot be undone.")
})
.primary_action("Delete", |_| {
delete_item();
})
.secondary_action("Cancel", |_| {
// Modal closes automatically
})
.show();
}
Dialogs
Centered dialogs with customizable content:
#![allow(unused)]
fn main() {
ctx.overlay_manager()
.dialog()
.title("Settings")
.content(|| build_settings_form())
.primary_action("Save", |_| {
save_settings();
})
.secondary_action("Cancel", |_| {})
.show();
}
Dialog Sizing
#![allow(unused)]
fn main() {
ctx.overlay_manager()
.dialog()
.width(600.0)
.height(400.0)
.title("Large Dialog")
.content(|| content)
.show();
}
Toasts
Brief notifications:
#![allow(unused)]
fn main() {
// Simple toast
ctx.overlay_manager()
.toast("Item saved successfully!")
.show();
// With duration
ctx.overlay_manager()
.toast("Processing...")
.duration(5000) // 5 seconds
.show();
// Positioned
ctx.overlay_manager()
.toast("Copied to clipboard")
.position(ToastPosition::BottomCenter)
.show();
}
Toast Positions
#![allow(unused)]
fn main() {
ToastPosition::TopLeft
ToastPosition::TopCenter
ToastPosition::TopRight
ToastPosition::BottomLeft
ToastPosition::BottomCenter
ToastPosition::BottomRight
}
Context Menus
Right-click menus:
#![allow(unused)]
fn main() {
div()
.on_context_menu(|evt| {
ctx.overlay_manager()
.context_menu()
.item("Copy", || copy_to_clipboard())
.item("Paste", || paste_from_clipboard())
.separator()
.item("Delete", || delete_selected())
.show_at(evt.mouse_x, evt.mouse_y);
})
}
Nested Menus
#![allow(unused)]
fn main() {
ctx.overlay_manager()
.context_menu()
.item("Edit", || {})
.submenu("Export", |menu| {
menu.item("PNG", || export_png())
.item("JPEG", || export_jpeg())
.item("SVG", || export_svg())
})
.show_at(x, y);
}
Dismissing Overlays
Overlays close when:
- User clicks outside (backdrop click)
- Escape key is pressed
- Action callback completes
- Programmatically dismissed
#![allow(unused)]
fn main() {
let overlay_id = ctx.overlay_manager()
.modal()
.title("Loading...")
.content(|| spinner())
.show();
// Later, dismiss programmatically
ctx.overlay_manager().dismiss(overlay_id);
}
Custom Overlay Content
For full control, use a custom overlay:
#![allow(unused)]
fn main() {
ctx.overlay_manager()
.custom(|| {
stack()
.w_full()
.h_full()
// Backdrop
.child(
div()
.w_full()
.h_full()
.bg(Color::rgba(0.0, 0.0, 0.0, 0.5))
)
// Content
.child(
div()
.absolute()
.inset(0.0)
.flex_center()
.child(my_custom_modal())
)
})
.show();
}
Best Practices
-
Use appropriate overlay type - Modal for blocking actions, toast for notifications, dialog for forms.
-
Provide escape routes - Always include a way to close (cancel button, backdrop click).
-
Keep toasts brief - Short messages that don’t require action.
-
Position context menus near cursor - Use event coordinates for natural placement.
-
Limit overlay nesting - Avoid opening overlays from within overlays.
Custom State Machines
For complex interactions beyond hover/press, define custom state types with the StateTransitions trait.
Defining Custom States
#![allow(unused)]
fn main() {
use blinc_layout::stateful::StateTransitions;
use blinc_core::events::event_types::*;
#[derive(Clone, Copy, PartialEq, Eq, Hash, Default)]
enum PlayerState {
#[default]
Stopped,
Playing,
Paused,
}
impl StateTransitions for PlayerState {
fn on_event(&self, event: u32) -> Option<Self> {
match (self, event) {
// Click cycles through states
(PlayerState::Stopped, POINTER_UP) => Some(PlayerState::Playing),
(PlayerState::Playing, POINTER_UP) => Some(PlayerState::Paused),
(PlayerState::Paused, POINTER_UP) => Some(PlayerState::Playing),
_ => None,
}
}
}
}
Using Custom States
#![allow(unused)]
fn main() {
use blinc_layout::prelude::*;
fn player_button() -> impl ElementBuilder {
stateful::<PlayerState>()
.w(60.0)
.h(60.0)
.rounded_full()
.flex_center()
.on_state(|ctx| {
let bg = match ctx.state() {
PlayerState::Stopped => Color::rgba(0.3, 0.3, 0.35, 1.0),
PlayerState::Playing => Color::rgba(0.2, 0.8, 0.4, 1.0),
PlayerState::Paused => Color::rgba(0.9, 0.6, 0.2, 1.0),
};
div().bg(bg).child(text("▶").color(Color::WHITE))
})
}
}
Event Types
Available event types for state transitions:
#![allow(unused)]
fn main() {
use blinc_core::events::event_types::*;
POINTER_ENTER // Mouse enters element
POINTER_LEAVE // Mouse leaves element
POINTER_DOWN // Mouse button pressed
POINTER_UP // Mouse button released (click)
POINTER_MOVE // Mouse moved over element
KEY_DOWN // Keyboard key pressed
KEY_UP // Keyboard key released
TEXT_INPUT // Character typed
FOCUS // Element gained focus
BLUR // Element lost focus
SCROLL // Scroll event
DRAG // Drag motion
DRAG_END // Drag completed
}
Multi-Phase Interactions
Drag State Machine
#![allow(unused)]
fn main() {
#[derive(Clone, Copy, PartialEq, Eq, Hash, Default)]
enum DragPhase {
#[default]
Idle,
Hovering,
Pressing,
Dragging,
}
impl StateTransitions for DragPhase {
fn on_event(&self, event: u32) -> Option<Self> {
match (self, event) {
// Enter hover
(DragPhase::Idle, POINTER_ENTER) => Some(DragPhase::Hovering),
(DragPhase::Hovering, POINTER_LEAVE) => Some(DragPhase::Idle),
// Start press
(DragPhase::Hovering, POINTER_DOWN) => Some(DragPhase::Pressing),
// Transition to drag on move while pressed
(DragPhase::Pressing, DRAG) => Some(DragPhase::Dragging),
// Release
(DragPhase::Pressing, POINTER_UP) => Some(DragPhase::Hovering),
(DragPhase::Dragging, DRAG_END) => Some(DragPhase::Idle),
_ => None,
}
}
}
fn draggable_card() -> impl ElementBuilder {
stateful::<DragPhase>()
.w(120.0)
.h(80.0)
.rounded(8.0)
.on_state(|ctx| {
let (bg, cursor) = match ctx.state() {
DragPhase::Idle => (Color::BLUE, "default"),
DragPhase::Hovering => (Color::CYAN, "grab"),
DragPhase::Pressing => (Color::YELLOW, "grabbing"),
DragPhase::Dragging => (Color::GREEN, "grabbing"),
};
div().bg(bg).cursor(cursor)
})
}
}
Focus State Machine
#![allow(unused)]
fn main() {
#[derive(Clone, Copy, PartialEq, Eq, Hash, Default)]
enum InputFocus {
#[default]
Idle,
Hovered,
Focused,
FocusedHovered,
}
impl StateTransitions for InputFocus {
fn on_event(&self, event: u32) -> Option<Self> {
match (self, event) {
// Hover transitions
(InputFocus::Idle, POINTER_ENTER) => Some(InputFocus::Hovered),
(InputFocus::Hovered, POINTER_LEAVE) => Some(InputFocus::Idle),
(InputFocus::Focused, POINTER_ENTER) => Some(InputFocus::FocusedHovered),
(InputFocus::FocusedHovered, POINTER_LEAVE) => Some(InputFocus::Focused),
// Focus transitions
(InputFocus::Idle, FOCUS) => Some(InputFocus::Focused),
(InputFocus::Hovered, FOCUS) => Some(InputFocus::FocusedHovered),
(InputFocus::Hovered, POINTER_UP) => Some(InputFocus::FocusedHovered),
(InputFocus::Focused, BLUR) => Some(InputFocus::Idle),
(InputFocus::FocusedHovered, BLUR) => Some(InputFocus::Hovered),
_ => None,
}
}
}
fn focusable_input() -> impl ElementBuilder {
stateful::<InputFocus>()
.w(200.0)
.h(40.0)
.rounded(4.0)
.on_state(|ctx| {
let (border_color, border_width) = match ctx.state() {
InputFocus::Idle => (Color::GRAY, 1.0),
InputFocus::Hovered => (Color::LIGHT_GRAY, 1.0),
InputFocus::Focused => (Color::BLUE, 2.0),
InputFocus::FocusedHovered => (Color::BLUE, 2.0),
};
div().border(border_width, border_color)
})
}
}
Combining with External State
Use .deps() to combine state machine transitions with external signals:
#![allow(unused)]
fn main() {
fn smart_button() -> impl ElementBuilder {
let enabled = use_state_keyed("enabled", || true);
stateful::<ButtonState>()
.px(16.0)
.py(8.0)
.rounded(8.0)
.deps([enabled.signal_id()])
.on_state(move |ctx| {
let is_enabled = enabled.get();
let bg = if !is_enabled {
Color::rgba(0.2, 0.2, 0.25, 0.5) // Disabled
} else {
match ctx.state() {
ButtonState::Idle => Color::rgba(0.3, 0.5, 0.9, 1.0),
ButtonState::Hovered => Color::rgba(0.4, 0.6, 1.0, 1.0),
ButtonState::Pressed => Color::rgba(0.2, 0.4, 0.8, 1.0),
_ => Color::rgba(0.3, 0.5, 0.9, 1.0),
}
};
div().bg(bg).child(text("Submit").color(Color::WHITE))
})
}
}
Accessing Dependencies via Context
Use ctx.dep() for cleaner dependency access:
#![allow(unused)]
fn main() {
fn counter_button(count: State<i32>) -> impl ElementBuilder {
stateful::<ButtonState>()
.deps([count.signal_id()])
.on_state(|ctx| {
// Access by index - no need to capture in closure
let value: i32 = ctx.dep(0).unwrap_or_default();
// Or get a State handle for reading/writing
if let Some(count_state) = ctx.dep_as_state::<i32>(0) {
// count_state.set(value + 1);
}
let bg = match ctx.state() {
ButtonState::Hovered => Color::CYAN,
_ => Color::BLUE,
};
div()
.bg(bg)
.child(text(&format!("Count: {}", value)))
})
}
}
Using Scoped State
StateContext provides scoped signals and animated values:
#![allow(unused)]
fn main() {
fn interactive_counter() -> impl ElementBuilder {
stateful::<ButtonState>()
.on_state(|ctx| {
// Scoped signal - persists across rebuilds
let clicks = ctx.use_signal("clicks", || 0);
// Scoped animated value with spring physics
let scale = ctx.use_animated_value("scale", 1.0);
// Animate based on state
match ctx.state() {
ButtonState::Pressed => {
scale.lock().unwrap().set_target(0.95);
}
_ => {
scale.lock().unwrap().set_target(1.0);
}
}
let s = scale.lock().unwrap().get();
div()
.transform(Transform::scale(s, s))
.child(text(&format!("Clicks: {}", clicks.get())))
.on_click(move |_| {
clicks.update(|n| n + 1);
})
})
}
}
State Debugging
Log state transitions for debugging:
#![allow(unused)]
fn main() {
impl StateTransitions for MyState {
fn on_event(&self, event: u32) -> Option<Self> {
let next = match (self, event) {
// ... transitions ...
_ => None,
};
if let Some(ref new_state) = next {
println!("State: {:?} -> {:?} (event: {})", self, new_state, event);
}
next
}
}
}
Setting Initial State
Use .initial() when you need a non-default starting state:
#![allow(unused)]
fn main() {
fn initially_disabled_button(disabled: bool) -> impl ElementBuilder {
stateful::<ButtonState>()
.initial(if disabled { ButtonState::Disabled } else { ButtonState::Idle })
.on_state(|ctx| {
let bg = match ctx.state() {
ButtonState::Disabled => Color::GRAY,
ButtonState::Idle => Color::BLUE,
ButtonState::Hovered => Color::CYAN,
ButtonState::Pressed => Color::DARK_BLUE,
};
div().bg(bg)
})
}
}
NoState for Dependency-Only Containers
When you only need dependency tracking without state transitions:
#![allow(unused)]
fn main() {
fn data_display(data: State<Vec<String>>) -> impl ElementBuilder {
stateful::<NoState>()
.deps([data.signal_id()])
.on_state(|ctx| {
// Access data via context
let items: Vec<String> = ctx.dep(0).unwrap_or_default();
div()
.flex_col()
.gap(4.0)
.children(items.iter().map(|item| {
div().child(text(item))
}))
})
}
}
Best Practices
-
Keep states minimal - Only include states you need to distinguish visually.
-
Handle all paths - Consider every possible event in each state.
-
Use descriptive names - State names should clearly indicate the UI appearance.
-
Return None for no-ops - If an event doesn’t cause a transition, return
None. -
Test transitions - Verify all state paths work as expected.
-
Use
.deps()for external dependencies - When combining with signals. -
Use
ctx.dep()over closures - Cleaner access to dependency values. -
Implement Default - Mark the default state with
#[default]attribute. -
Use scoped signals -
ctx.use_signal()for state local to the stateful. -
Use animated values -
ctx.use_animated_value()for smooth transitions.
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 let you write GPU fragment shaders using a high-level DAG (directed acyclic graph) that compiles to WGSL. They 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 (always fragment for visual effects), and a body of declarations:
@flow <name> {
target: fragment;
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 color = <expression>; // Output declarations
}
Declarations can appear in any order, but each node can only reference inputs and earlier nodes (the graph must be acyclic).
Builtin Variables
| 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 |
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 |
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)
}
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.
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);
}
}