Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Introduction

Blinc is a GPU-accelerated, reactive UI framework for Rust. It provides a declarative, component-based approach to building high-performance user interfaces with smooth animations and modern visual effects.

Why Blinc?

  • GPU-Accelerated Rendering - All rendering is done on the GPU via wgpu, enabling smooth 60fps animations and complex visual effects like glass materials and shadows.

  • Declarative UI - Build interfaces using a fluent, composable API inspired by SwiftUI and modern web frameworks. No manual DOM manipulation.

  • Reactive State - Automatic UI updates when state changes, with fine-grained reactivity for optimal performance.

  • Spring Physics - Natural, physics-based animations using spring dynamics instead of fixed durations.

  • Cross-Platform - Runs on macOS, Windows, Linux, Android, iOS and Web (WASM + WebGPU).

Key Features

Flexbox Layout

All layout is powered by Taffy, a high-performance flexbox implementation. Use familiar CSS-like properties:

#![allow(unused)]
fn main() {
div()
    .flex_col()
    .gap(16.0)
    .p(24.0)
    .child(text("Hello"))
    .child(text("World"))
}

Material Effects

Built-in support for glass, metallic, and other material effects:

#![allow(unused)]
fn main() {
div()
    .glass()
    .rounded(16.0)
    .p(24.0)
    .child(text("Frosted Glass"))
}

Type-Safe Animations

The BlincComponent derive macro generates type-safe animation hooks:

#![allow(unused)]
fn main() {
#[derive(BlincComponent)]
struct MyCard {
    #[animation]
    scale: f32,
    #[animation]
    opacity: f32,
}

// Usage
let scale = MyCard::use_scale(ctx, 1.0, SpringConfig::snappy());
let opacity = MyCard::use_opacity(ctx, 0.0, SpringConfig::gentle());
}

Event Handling

Intuitive event handling with closures:

#![allow(unused)]
fn main() {
div()
    .on_click(|_| println!("Clicked!"))
    .on_hover_enter(|_| println!("Hovered"))
}

Architecture Overview

┌─────────────────────────────────────────────────────┐
│                   Your Application                   │
├─────────────────────────────────────────────────────┤
│  blinc_app   │  WindowedApp, Context, State Hooks   │
├──────────────┼──────────────────────────────────────┤
│  blinc_layout│  Elements, Flexbox, Event Routing    │
├──────────────┼──────────────────────────────────────┤
│  blinc_animation │  Springs, Timelines, Motion      │
├──────────────┼──────────────────────────────────────┤
│  blinc_gpu   │  Render Pipeline, Materials          │
├──────────────┼──────────────────────────────────────┤
│  wgpu        │  GPU Abstraction Layer               │
└─────────────────────────────────────────────────────┘

Quick Example

Here’s a minimal Blinc application:

use blinc_app::prelude::*;
use blinc_app::windowed::{WindowedApp, WindowedContext};

fn main() -> Result<()> {
    WindowedApp::run(WindowConfig::default(), |ctx| {
        div()
            .w(ctx.width)
            .h(ctx.height)
            .bg(Color::rgba(0.1, 0.1, 0.15, 1.0))
            .flex_center()
            .child(
                div()
                    .glass()
                    .rounded(16.0)
                    .p(32.0)
                    .child(text("Hello, Blinc!").size(24.0).color(Color::WHITE))
            )
    })
}

For AI Agents

If you’re an AI coding agent working with Blinc, see Skills.md in the repository root — a concise, example-driven reference with verified APIs, CSS-first styling patterns, and common pitfalls.

Next Steps

Installation

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

FeatureDescription
windowedDesktop window support via winit (default)
androidAndroid 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.

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:

  1. Creates a window with the given configuration
  2. Sets up the GPU renderer
  3. Calls your UI builder function when needed
  4. Handles events and animations automatically

Building a Counter

Let’s create a counter with increment and decrement buttons.

Step 1: Window Configuration

use blinc_app::prelude::*;
use blinc_app::windowed::{WindowedApp, WindowedContext};

fn main() -> Result<()> {
    let config = WindowConfig {
        title: "Counter App".to_string(),
        width: 400,
        height: 300,
        resizable: true,
        ..Default::default()
    };

    WindowedApp::run(config, |ctx| build_ui(ctx))
}

Step 2: Creating State

Use use_state_keyed to create reactive state that persists across UI rebuilds:

#![allow(unused)]
fn main() {
fn build_ui(ctx: &WindowedContext) -> impl ElementBuilder {
    // Create keyed state for the count - persists across rebuilds
    let count = ctx.use_state_keyed("counter", || 0i32);

    // State will be read inside stateful elements via .deps()
    // ... rest of UI
}
}

Step 3: Building the Layout with Stateful Elements

The key insight in Blinc is that UI doesn’t rebuild on every state change. Instead, we use stateful::<S>() with .deps() to react to state changes:

#![allow(unused)]
fn main() {
use blinc_layout::stateful::stateful;

fn build_ui(ctx: &WindowedContext) -> impl ElementBuilder {
    let count = ctx.use_state_keyed("counter", || 0i32);

    div()
        .w(ctx.width)
        .h(ctx.height)
        .bg(Color::rgba(0.08, 0.08, 0.12, 1.0))
        .flex_col()
        .justify_center()
        .items_center()
        .gap(24.0)
        // Title
        .child(
            text("Counter")
                .size(32.0)
                .weight(FontWeight::Bold)
                .color(Color::WHITE)
        )
        // Count display - uses stateful with deps to update when count changes
        .child(count_display(count.clone()))
        // Buttons row
        .child(
            div()
                .flex_row()
                .gap(16.0)
                .child(counter_button(count.clone(), "-", -1))
                .child(counter_button(count.clone(), "+", 1))
        )
}
}

Step 4: Creating the Count Display

The count display needs to update when the count changes. We use stateful::<NoState>() with .deps():

#![allow(unused)]
fn main() {
fn count_display(count: State<i32>) -> impl ElementBuilder {
    stateful::<NoState>()
        .deps([count.signal_id()])
        .on_state(move |_ctx| {
            let current = count.get();
            div().child(
                text(&format!("{}", current))
                    .size(64.0)
                    .weight(FontWeight::Bold)
                    .color(Color::rgba(0.4, 0.6, 1.0, 1.0))
            )
        })
}
}

Step 5: Creating Interactive Buttons

For interactive buttons with hover and press states, use stateful::<ButtonState>():

#![allow(unused)]
fn main() {
fn counter_button(
    count: State<i32>,
    label: &'static str,
    delta: i32,
) -> impl ElementBuilder {
    stateful::<ButtonState>()
        .w(60.0)
        .h(60.0)
        .rounded(12.0)
        .flex_center()
        .on_state(|ctx| {
            // Apply different styles based on current state
            let bg = match ctx.state() {
                ButtonState::Idle => Color::rgba(0.2, 0.2, 0.25, 1.0),
                ButtonState::Hovered => Color::rgba(0.3, 0.3, 0.35, 1.0),
                ButtonState::Pressed => Color::rgba(0.15, 0.15, 0.2, 1.0),
                ButtonState::Disabled => Color::rgba(0.1, 0.1, 0.12, 0.5),
            };
            div().bg(bg)
        })
        .on_click(move |_| {
            count.update(|v| v + delta);
        })
        .child(
            text(label)
                .size(28.0)
                .weight(FontWeight::Bold)
                .color(Color::WHITE)
        )
}
}

Complete Example

Here’s the full counter application:

use blinc_app::prelude::*;
use blinc_app::windowed::{WindowedApp, WindowedContext};
use blinc_layout::stateful::stateful;

fn main() -> Result<()> {
    tracing_subscriber::fmt()
        .with_max_level(tracing::Level::INFO)
        .init();

    let config = WindowConfig {
        title: "Counter App".to_string(),
        width: 400,
        height: 300,
        resizable: true,
        ..Default::default()
    };

    WindowedApp::run(config, |ctx| build_ui(ctx))
}

fn build_ui(ctx: &WindowedContext) -> impl ElementBuilder {
    let count = ctx.use_state_keyed("counter", || 0i32);

    div()
        .w(ctx.width)
        .h(ctx.height)
        .bg(Color::rgba(0.08, 0.08, 0.12, 1.0))
        .flex_col()
        .justify_center()
        .items_center()
        .gap(24.0)
        .child(
            text("Counter")
                .size(32.0)
                .weight(FontWeight::Bold)
                .color(Color::WHITE)
        )
        .child(count_display(count.clone()))
        .child(
            div()
                .flex_row()
                .gap(16.0)
                .child(counter_button(count.clone(), "-", -1))
                .child(counter_button(count.clone(), "+", 1))
        )
}

fn count_display(count: State<i32>) -> impl ElementBuilder {
    stateful::<NoState>()
        .deps([count.signal_id()])
        .on_state(move |_ctx| {
            let current = count.get();
            div().child(
                text(&format!("{}", current))
                    .size(64.0)
                    .weight(FontWeight::Bold)
                    .color(Color::rgba(0.4, 0.6, 1.0, 1.0))
            )
        })
}

fn counter_button(
    count: State<i32>,
    label: &'static str,
    delta: i32,
) -> impl ElementBuilder {
    stateful::<ButtonState>()
        .w(60.0)
        .h(60.0)
        .rounded(12.0)
        .flex_center()
        .on_state(|ctx| {
            let bg = match ctx.state() {
                ButtonState::Idle => Color::rgba(0.2, 0.2, 0.25, 1.0),
                ButtonState::Hovered => Color::rgba(0.3, 0.3, 0.35, 1.0),
                ButtonState::Pressed => Color::rgba(0.15, 0.15, 0.2, 1.0),
                ButtonState::Disabled => Color::rgba(0.1, 0.1, 0.12, 0.5),
            };
            div().bg(bg)
        })
        .on_click(move |_| {
            count.update(|v| v + delta);
        })
        .child(
            text(label)
                .size(28.0)
                .weight(FontWeight::Bold)
                .color(Color::WHITE)
        )
}

Tip: For more examples, explore the examples/blinc_app_examples/examples/ directory which includes windowed.rs, canvas_demo.rs, motion_demo.rs, and more.

Key Concepts Learned

  1. WindowedApp::run - Entry point for desktop applications
  2. WindowedContext - Provides window dimensions and state hooks
  3. use_state_keyed - Creates reactive state with a string key
  4. stateful::<S>() - Creates elements that react to state changes
  5. deps() - Declares signal dependencies for reactive updates
  6. on_state - Callback that runs when state or dependencies change
  7. Fluent Builder API - Chain methods like .w(), .h(), .child()
  8. Flexbox Layout - Use .flex_col(), .flex_center(), .gap()

Next Steps

Project Structure

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

  1. Keep components small - Each component should do one thing well
  2. Use BlincComponent - For any component with animations or complex state
  3. Separate concerns - UI building, state management, and business logic
  4. Use the prelude - use blinc_app::prelude::* imports common items
  5. Consistent naming - Use _screen suffix for full-page views, no suffix for components

Mobile Development

Blinc supports building native mobile applications for both Android and iOS. The same Rust UI code runs on mobile with platform-specific rendering backends (Vulkan for Android, Metal for iOS) and a unified API for native platform features.

Cross-Platform Architecture

┌─────────────────────────────────────────────────────────────┐
│                      Your Blinc App                          │
│         (Shared Rust UI code, state, animations)             │
└─────────────────────────────┬───────────────────────────────┘
                              │
         ┌────────────────────┼────────────────────┐
         │                    │                    │
    ┌────▼────┐         ┌─────▼─────┐        ┌────▼────┐
    │ Desktop │         │  Android  │        │   iOS   │
    │ (wgpu)  │         │ (Vulkan)  │        │ (Metal) │
    └─────────┘         └───────────┘        └─────────┘

Key Features

  • Shared UI Code: Write your UI once in Rust, deploy everywhere
  • Native Performance: GPU-accelerated rendering via Vulkan/Metal
  • Touch Support: Full multi-touch gesture handling
  • Native Bridge: Typed function-call protocol between Rust and Kotlin/Swift
  • Reactive State: Same reactive state system as desktop
  • Animations: Spring physics and keyframe animations work seamlessly

Supported Platforms

PlatformBackendMin VersionStatus
AndroidVulkanAPI 24 (7.0)Stable
iOSMetaliOS 15+Stable

Project Structure

A typical Blinc mobile project (matches mobile/example/ in this repo):

my-app/
├── Cargo.toml              # Rust workspace + cdylib/staticlib config
├── blinc.toml              # Blinc project config
├── .cargo/                 # Per-target cargo config (linker, flags)
├── .env                    # SDK / NDK / signing paths (gitignored)
├── .env.example            # Template for .env
├── src/
│   └── main.rs             # Shared Rust UI code
├── platforms/
│   ├── android/            # Android Gradle project
│   │   ├── app/
│   │   │   ├── build.gradle.kts
│   │   │   └── src/main/
│   │   │       ├── AndroidManifest.xml
│   │   │       └── kotlin/com/blinc/
│   │   │           ├── MainActivity.kt
│   │   │           └── BlincNativeBridge.kt
│   │   ├── build.gradle.kts
│   │   └── settings.gradle.kts
│   ├── ios/                # iOS Xcode project
│   │   ├── BlincApp/
│   │   │   ├── AppDelegate.swift
│   │   │   ├── BlincViewController.swift
│   │   │   ├── BlincMetalView.swift
│   │   │   ├── BlincNativeBridge.swift
│   │   │   ├── Blinc-Bridging-Header.h
│   │   │   ├── Info.plist
│   │   │   └── Fonts/
│   │   └── BlincApp.xcodeproj/
│   └── harmony/            # HarmonyOS (in progress)
├── build-android.sh        # Cross-compile + copy .so → jniLibs
├── build-ios.sh            # Cross-compile + copy .a → libs/{device,simulator}
└── build-ohos.sh           # HarmonyOS build script

Quick Start

blinc new my-app --template rust
cd my-app
blinc run android   # or: blinc run ios
#![allow(unused)]
fn main() {
use blinc_app::prelude::*;

fn app(ctx: &mut WindowedContext) -> impl ElementBuilder {
    let count = ctx.use_state_keyed("count", || 0i32);

    div()
        .w(ctx.width).h(ctx.height)
        .bg(Color::from_hex(0x1a1a2e))
        .flex_col().items_center().justify_center().gap(20.0)
        .child(text(format!("Count: {}", count.get())).size(48.0).color(Color::WHITE))
        .child(
            button(state.clone(), "+")
                .on_click(move |_| count.set(count.get() + 1))
        )
}
}

Native Bridge

Blinc’s native bridge provides a typed function-call protocol between Rust and Kotlin/Swift. Use it for any platform feature not in the framework core: camera, biometrics, push notifications, native dialogs, etc.

Setup required. The bridge does NOT work out of the box — you must wire it up at app startup on each platform. The example project (mobile/example/) shows the canonical wiring; copy the relevant bits into your own MainActivity.kt and AppDelegate.swift. Without this, every native_call will fail with “handler not found”.

Rust side — call into native

#![allow(unused)]
fn main() {
use blinc_core::native_bridge::native_call;

// Synchronous call returning a value
let level: String = native_call("device", "get_battery_level", ())?;

// Pass arguments
native_call::<(), _>("notify", "show", ("Hello", "World"))?;

// Built-in haptic helpers
native_call::<(), _>("haptics", "selection", ())?;
native_call::<(), _>("haptics", "impact", (1i32,))?; // 0=light, 1=medium, 2=heavy
native_call::<(), _>("haptics", "success", ())?;
}

Kotlin side — register handlers

Copy BlincNativeBridge.kt from mobile/example/platforms/android/app/src/main/kotlin/com/blinc/ into your project — it’s the JNI shim that Rust calls into.

// MainActivity.kt — companion object init block:
companion object {
    init {
        System.loadLibrary("my_app")
    }
}

// In onCreate:
override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)

    // REQUIRED: register the built-in handlers (haptics, device info,
    // keyboard show/hide, clipboard) before the Rust frame loop starts.
    BlincNativeBridge.registerDefaults(this)

    // Optional: register your own custom handlers
    BlincNativeBridge.registerString("device", "get_battery_level") {
        val bm = getSystemService(Context.BATTERY_SERVICE) as BatteryManager
        bm.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY).toString()
    }

    BlincNativeBridge.registerVoid("notify", "show") { args ->
        val title = args.getString(0)
        val body = args.getString(1)
        NotificationHelper.show(this, title, body)
    }
}

Swift side — register handlers

Copy BlincNativeBridge.swift from mobile/example/platforms/ios/BlincApp/ into your project — it’s the C-FFI shim that Rust calls into.

// AppDelegate.swift — application(_:didFinishLaunchingWithOptions:)
func application(
    _ application: UIApplication,
    didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
    // REQUIRED: register defaults BEFORE connectToRust so the
    // function pointer table is populated when Rust starts calling.
    BlincNativeBridge.shared.registerDefaults()
    BlincNativeBridge.shared.connectToRust()

    // Optional: register your own custom handlers
    BlincNativeBridge.shared.registerString(
        namespace: "device",
        name: "get_battery_level"
    ) { _ in
        UIDevice.current.isBatteryMonitoringEnabled = true
        return String(Int(UIDevice.current.batteryLevel * 100))
    }

    BlincNativeBridge.shared.registerVoid(
        namespace: "notify",
        name: "show"
    ) { args in
        let title = args[0] as? String ?? ""
        let body = args[1] as? String ?? ""
        NotificationHelper.show(title: title, body: body)
    }

    return true
}

Order matters: registerDefaults() must be called BEFORE connectToRust() so the Swift-side handler table is populated when Rust starts dispatching calls.


Streams (camera, audio, sensors)

Streams deliver continuous data (frames, samples, sensor readings) from the platform back to Rust without polling. The platform pushes data via dispatch_stream_data, which fires the registered Rust callback. Drop the returned NativeStream handle to stop the stream and release resources.

#![allow(unused)]
fn main() {
use blinc_core::native_bridge::{native_stream, NativeValue};

let stream = native_stream(
    "sensors",
    "accelerometer",
    NativeValue::Null,
    |data| {
        if let Some(arr) = data.as_array() {
            let x = arr[0].as_f32().unwrap_or(0.0);
            let y = arr[1].as_f32().unwrap_or(0.0);
            let z = arr[2].as_f32().unwrap_or(0.0);
            println!("accel: {x}, {y}, {z}");
        }
    },
)?;
// drop(stream) → stream stops
}

The platform side calls nativeDispatchStreamData(streamId, byteArray) (Android JNI) or blinc_dispatch_stream_data(stream_id, ptr, len) (iOS C FFI) to push data into the Rust callback.

Camera capture

CameraStream from blinc_media wraps the bridge stream API in a typed reactive interface:

#![allow(unused)]
fn main() {
use blinc_media::{CameraStream, CameraConfig, CameraFacing};

let camera = CameraStream::open(CameraConfig {
    width: 640,
    height: 480,
    fps: 30,
    facing: CameraFacing::Front,
});

// Read latest frame in build_ui
if let Some(frame) = camera.latest_frame() {
    canvas(move |ctx, bounds| {
        ctx.draw_rgba_pixels(frame.as_rgba(), frame.width, frame.height, bounds);
    })
}

// drop(camera) stops capture and releases the device
}

The platform side uses Camera2 (Android) or AVCaptureSession (iOS) and pushes frames through the native bridge stream protocol.

Note: A complete camera demo example is on the roadmap. The API surface above is stable.

Audio recording

#![allow(unused)]
fn main() {
use blinc_media::{AudioRecorder, AudioRecorderConfig};

let recorder = AudioRecorder::open(AudioRecorderConfig {
    sample_rate: 44100,
    channels: 1,
});

if let Some(samples) = recorder.latest_samples() {
    process_audio(samples.as_f32());
}
}

Platform side: AudioRecord (Android) or AVAudioRecorder (iOS) streams 16-bit PCM through the bridge.


Deep Linking

Blinc Router auto-handles deep links — no manual wiring required after RouterBuilder::build().

Rust — define routes

#![allow(unused)]
fn main() {
use blinc_router::RouterBuilder;

let router = RouterBuilder::new()
    .route("/", home_page)
    .route("/users/:id", user_detail)
    .route("/products/:slug", product_page)
    .build();

// router is auto-wired to dispatch_deep_link
// myapp://users/42 → router.push("/users/42") → user_detail({id: "42"})
}

Android — forward intents to Rust

// MainActivity.kt
override fun onNewIntent(intent: Intent) {
    super.onNewIntent(intent)
    intent.data?.toString()?.let { uri ->
        nativeDispatchDeepLink(uri)
    }
}

external fun nativeDispatchDeepLink(uri: String)

iOS — forward URLs to Rust

// AppDelegate.swift
func application(
    _ app: UIApplication,
    open url: URL,
    options: [UIApplication.OpenURLOptionsKey : Any] = [:]
) -> Bool {
    blinc_ios_handle_deep_link(url.absoluteString)
    return true
}

// SceneDelegate.swift (for scene-based apps)
func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) {
    URLContexts.forEach { ctx in
        blinc_ios_handle_deep_link(ctx.url.absoluteString)
    }
}

The system back button is also auto-registered: Key::Back events route through router.back().


App Lifecycle

#![allow(unused)]
fn main() {
use blinc_platform::event::{Event, LifecycleEvent};

match event {
    Event::Lifecycle(LifecycleEvent::Resumed) => {
        camera.resume();
        analytics.session_start();
    }
    Event::Lifecycle(LifecycleEvent::Suspended) => {
        camera.pause();
        save_state();
    }
    Event::Lifecycle(LifecycleEvent::LowMemory) => {
        clear_image_cache();
    }
    _ => {}
}
}
Blinc EventAndroidiOS
ResumedMainEvent::ResumeapplicationDidBecomeActive
SuspendedMainEvent::PauseapplicationWillResignActive
LowMemoryMainEvent::LowMemoryapplicationDidReceiveMemoryWarning

Soft Keyboard

Text input widgets (text_input(), text_area()) automatically show/hide the soft keyboard on focus. The keyboard inset is reported back via WindowedContext.safe_bottom() so your layout can adjust.

#![allow(unused)]
fn main() {
text_input(state)
    .placeholder("Type something...")
}

Implementation:

  • Android: keyboard show/hide commands dispatched via the native bridge under keyboard.show / keyboard.hide. Default handlers (registered by BlincNativeBridge.registerDefaults) call InputMethodManager.showSoftInput / hideSoftInputFromWindow.
  • iOS: blinc_ios_show_keyboard() / blinc_ios_hide_keyboard() C FFI invoked from the frame loop. Inset reported back via blinc_ios_set_keyboard_inset(ctx, inset) from a keyboardWillShow observer.

Edit Menu (iOS 16+)

Text input widgets automatically integrate with UIEditMenuInteraction on iOS 16+. Long-press a text field to see the system Cut/Copy/Paste/Select menu — no manual wiring required. The native bridge handles UIPasteboard clipboard read/write, menu presentation, and word selection.


Safe Area Insets

WindowedContext exposes the OS-reported safe-area insets — notch, status bar, nav bar, home indicator, gesture bar, landscape camera cutouts — in logical pixels, matching ctx.width / ctx.height:

#![allow(unused)]
fn main() {
pub fn build_ui(ctx: &mut WindowedContext) -> impl ElementBuilder {
    div()
        .w(ctx.width).h(ctx.height)
        .pt(ctx.safe_top())     // status bar / notch
        .pb(ctx.safe_bottom())  // home indicator / gesture bar
        .pl(ctx.safe_left())    // landscape notch
        .pr(ctx.safe_right())
        .child(/* ... */)
}
}
  • iOS: read from UIWindow.safeAreaInsets via objc2 at context-creation time. Fetched from the first key window of the first foreground-active UIWindowScene.
  • Android: delivered by BlincNativeBridge’s setOnApplyWindowInsetsListener on the decor view. On API 30+ it merges WindowInsets.Type.systemBars() with WindowInsets.Type.displayCutout() so landscape notches are covered; on API 24–29 it falls back to the (deprecated but functional) systemWindowInset* accessors. The four values are pushed into Rust via the nativeDispatchSafeArea JNI export; the android_main poll loop copies them into WindowedContext.safe_area whenever an edge changes (rotation, split-screen, PiP exit, immersive-mode toggle).
  • Desktop / Web / Fuchsia: always (0, 0, 0, 0).

safe_width() / safe_height() return the content rect with both horizontal or both vertical insets subtracted, for when you want the full safe content area as a single number.


Touch Event Handling

Touch events are automatically routed to your UI:

Android ActioniOS PhaseBlinc Event
ACTION_DOWNtouchesBeganpointer_down
ACTION_MOVEtouchesMovedpointer_move
ACTION_UPtouchesEndedpointer_up + pointer_leave
ACTION_CANCELtouchesCancelledpointer_leave

Two-finger pinch gestures emit PINCH events with center + scale. Use .on_pinch() and .on_rotate() on a Div to receive them.


Next Steps

Android Project Setup

This guide covers setting up an Android Blinc project — toolchain, build commands, and the platform-specific files (AndroidManifest.xml, Gradle config, debugging).

For the cross-platform Blinc API (native bridge, camera, deep linking, lifecycle, etc.), see the Mobile Development overview.

Prerequisites

1. Android SDK & NDK

# macOS
brew install --cask android-studio

export ANDROID_HOME=$HOME/Library/Android/sdk
export ANDROID_NDK_HOME=$ANDROID_HOME/ndk/26.1.10909125
export PATH=$PATH:$ANDROID_HOME/platform-tools

2. Rust Targets

rustup target add aarch64-linux-android
rustup target add armv7-linux-androideabi
rustup target add x86_64-linux-android
cargo install cargo-ndk

Building

# Debug — single arch
cargo ndk -t arm64-v8a build

# Release — multi-arch
cargo ndk -t arm64-v8a -t armeabi-v7a build --release

# Or via Gradle (from platforms/android/)
./gradlew assembleDebug

The APK lands in app/build/outputs/apk/debug/app-debug.apk.

Project Configuration

Cargo.toml

[lib]
name = "my_app"
crate-type = ["cdylib", "staticlib"]

[target.'cfg(target_os = "android")'.dependencies]
blinc_app = { version = "0.5", features = ["android"] }
blinc_platform_android = "0.5"
android-activity = { version = "0.6", features = ["native-activity"] }
log = "0.4"
android_logger = "0.14"

AndroidManifest.xml

<manifest xmlns:android="http://schemas.android.com/apk/res/android">
    <uses-feature android:glEsVersion="0x00030000" android:required="true" />

    <!-- Permissions for native bridge features -->
    <uses-permission android:name="android.permission.CAMERA" />
    <uses-permission android:name="android.permission.RECORD_AUDIO" />
    <uses-permission android:name="android.permission.VIBRATE" />
    <uses-permission android:name="android.permission.INTERNET" />

    <application
        android:label="My App"
        android:theme="@android:style/Theme.DeviceDefault.NoActionBar.Fullscreen"
        android:hardwareAccelerated="true">

        <activity
            android:name=".MainActivity"
            android:configChanges="orientation|screenSize|keyboardHidden"
            android:exported="true"
            android:launchMode="singleTask">

            <meta-data
                android:name="android.app.lib_name"
                android:value="my_app" />

            <intent-filter>
                <action android:name="android.intent.action.MAIN" />
                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>

            <!-- Deep link: myapp://path/to/route -->
            <intent-filter android:autoVerify="true">
                <action android:name="android.intent.action.VIEW" />
                <category android:name="android.intent.category.DEFAULT" />
                <category android:name="android.intent.category.BROWSABLE" />
                <data android:scheme="myapp" />
            </intent-filter>
        </activity>
    </application>
</manifest>

Debugging

# View Rust logs
adb logcat | grep -E "(blinc|BlincApp)"

# Filter for native bridge calls
adb logcat | grep BlincNativeBridge

Common Issues

“Library not found” — ensure the native library is built and copied to app/src/main/jniLibs/<arch>/:

cargo ndk -t arm64-v8a build
cp target/aarch64-linux-android/debug/libmy_app.so \
   platforms/android/app/src/main/jniLibs/arm64-v8a/

“Vulkan not supported” — check device capability:

adb shell getprop ro.hardware.vulkan

API 24+ devices generally support Vulkan, but some emulators may not.

“Native call failed” — verify the namespace+name matches between Kotlin and Rust handlers. Check logcat for BlincNativeBridge: handler not found for X.Y.

Touch events not working — verify the render context is created successfully and android.app.lib_name in the manifest matches your library name.

Performance

[profile.release]
lto = "fat"
opt-level = "z"      # optimize for size on mobile
panic = "abort"
strip = true
codegen-units = 1
  • Test on real devices — emulators have different GPU characteristics
  • Profile with Android Studio Profiler for CPU/GPU/memory
  • Bundle assets via assets/AndroidAssetLoader auto-resolves them through the platform AssetLoader trait

Next Steps

iOS Project Setup

This guide covers setting up an iOS Blinc project — toolchain, build commands, and the platform-specific files (Info.plist, Xcode configuration, debugging).

For the cross-platform Blinc API (native bridge, camera, deep linking, lifecycle, etc.), see the Mobile Development overview.

Prerequisites

1. Xcode

Install Xcode 15+ from the App Store.

xcode-select -p

2. Rust Targets

rustup target add aarch64-apple-ios        # Device
rustup target add aarch64-apple-ios-sim    # Simulator (Apple Silicon)
rustup target add x86_64-apple-ios         # Simulator (Intel)

Building

Create a build script build-ios.sh:

#!/bin/bash
set -e
MODE=${1:-debug}
PROJECT_NAME="my_app"
[ "$MODE" = "release" ] && CARGO_FLAGS="--release" || CARGO_FLAGS=""
TARGET_DIR=$([ "$MODE" = "release" ] && echo "release" || echo "debug")

cargo build --target aarch64-apple-ios $CARGO_FLAGS
cargo build --target aarch64-apple-ios-sim $CARGO_FLAGS

mkdir -p platforms/ios/libs/{device,simulator}
cp target/aarch64-apple-ios/$TARGET_DIR/lib${PROJECT_NAME}.a \
   platforms/ios/libs/device/
cp target/aarch64-apple-ios-sim/$TARGET_DIR/lib${PROJECT_NAME}.a \
   platforms/ios/libs/simulator/
./build-ios.sh         # debug
./build-ios.sh release

Then open platforms/ios/BlincApp.xcodeproj in Xcode and press Cmd+R.

Project Configuration

Cargo.toml

[lib]
name = "my_app"
crate-type = ["cdylib", "staticlib"]

[target.'cfg(target_os = "ios")'.dependencies]
blinc_app = { version = "0.5", features = ["ios"] }
blinc_platform_ios = "0.5"

Xcode Build Settings

  1. Link static library: Build Phases → Link Binary With Libraries → add libmy_app.a from libs/device/ or libs/simulator/
  2. Bridging header: Build Settings → Objective-C Bridging Header → BlincApp/Blinc-Bridging-Header.h
  3. Frameworks:
    • Metal.framework
    • MetalKit.framework
    • QuartzCore.framework
    • AVFoundation.framework (camera/audio)
    • CoreHaptics.framework (haptics)

Info.plist

<!-- Camera + microphone permissions for native bridge features -->
<key>NSCameraUsageDescription</key>
<string>This app uses the camera for photo capture.</string>
<key>NSMicrophoneUsageDescription</key>
<string>This app records audio.</string>

<!-- Deep link URL scheme -->
<key>CFBundleURLTypes</key>
<array>
    <dict>
        <key>CFBundleURLSchemes</key>
        <array><string>myapp</string></array>
    </dict>
</array>

Bridging Header

The bridging header (Blinc-Bridging-Header.h) declares the C FFI surface Swift uses to call Rust:

// Context lifecycle
IOSRenderContext* blinc_create_context(uint32_t width, uint32_t height, double scale);
void blinc_destroy_context(IOSRenderContext* ctx);

// Rendering
bool blinc_needs_render(IOSRenderContext* ctx);
void blinc_build_frame(IOSRenderContext* ctx);
bool blinc_render_frame(IOSGpuRenderer* gpu);

// Input
void blinc_handle_touch(IOSRenderContext* ctx, uint64_t id, float x, float y, int32_t phase);

// Deep linking + keyboard (see Mobile overview for usage)
void blinc_ios_handle_deep_link(const char* uri);
void blinc_ios_set_keyboard_inset(IOSRenderContext* ctx, float inset);

The BlincViewController template manages the CADisplayLink, CAMetalLayer, and touch event forwarding to Rust.

Debugging

Console Logs

View Rust logs in Xcode’s console or Console.app with a filter:

subsystem:com.blinc.my_app

Common Issues

“Library not found: -lmy_app” — run ./build-ios.sh first.

Black screen on simulator

  1. Verify the right simulator target (aarch64-apple-ios-sim for Apple Silicon, x86_64-apple-ios for Intel)
  2. Verify the static library is in libs/simulator/
  3. Check Xcode console for Metal initialization errors

Touch events not working

  1. Verify blinc_create_context succeeds (check console)
  2. Ensure ios_app_init() is called before creating the context
  3. Touch coordinates must be in logical points, not physical pixels

Native call failed — verify Swift handler is registered with matching namespace.name. Check that BlincNativeBridge.shared.connectToRust() was called at app launch.

Performance

[profile.release]
lto = "fat"
opt-level = "z"
panic = "abort"
strip = true
codegen-units = 1
  • Test on real devices — simulators use software rendering for some Metal operations
  • Profile with Instruments — use the Metal System Trace template for GPU analysis

Next Steps

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:

  1. Build the native library
  2. Build the APK with Gradle
  3. Install on connected device/emulator
  4. 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:

  1. Build the static library
  2. Open Xcode project
  3. 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

KeyDescriptionDefault
project.nameProject nameRequired
project.versionVersion string“0.1.0”
project.templateTemplate type“rust”
targets.defaultDefault build target“desktop”
targets.supportedList of supported platforms[“desktop”]
build.blinc_pathPath to Blinc framework“../..”

Cleaning

# Clean all build artifacts
blinc clean

# Clean specific platform
blinc clean android
blinc clean ios

Checking Configuration

# Validate project configuration
blinc check

# Check specific platform setup
blinc check android
blinc check ios

This verifies:

  • Required tools are installed
  • Environment variables are set
  • Project configuration is valid

Example Gallery

Every example in examples/blinc_app_examples/examples/ that follows the cross-target convention is auto-built for the web target by tools/build-web-examples and embedded below. The same build_ui function that runs on desktop, iOS, and Android runs here — no per-target forks. See the Contributing → Examples page for the convention that makes this work.

Click any card to open the example in a focused view with a lazy-loaded iframe. Each demo spawns its own WebGPU context, so loading more than ~8 at once will start hitting Chrome’s per-tab GPU context limit — the per-example pages keep that manageable.

Examples

Canvas Element

This example demonstrates the canvas element for custom GPU drawing within the layout system.

Features demonstrated:

  • Custom 2D drawing with DrawContext
  • Canvas respects layout transforms and clipping
  • Procedural graphics (animated shapes, patterns)
  • Canvas for cursor/indicator rendering
  • BlincComponent derive macro for type-safe animation hooks

Tip: Some demos are best viewed in a full browser window. Click “Open in a new tab” below for the full experience.

Open in a new tab · View source on GitHub

Canvas Kit Interactive

Demonstrates blinc_canvas_kit features:

  • Pan (drag background) and zoom (scroll wheel) on an infinite canvas
  • kit.element() builder with auto-wired event handlers
  • kit.handler() for custom event wiring
  • Hit testing via kit.hit_rect() inside draw callbacks
  • Click, drag, and hover callbacks on canvas-drawn elements
  • Viewport state HUD overlay

Tip: Some demos are best viewed in a full browser window. Click “Open in a new tab” below for the full experience.

Open in a new tab · View source on GitHub

Carousel Demo - Selector API Showcase

Demonstrates the new selector API features:

  • ScrollRef for programmatic scroll control
  • Element IDs for targeting elements
  • scroll_to() to scroll to elements by ID
  • ScrollOptions with ScrollBlock::Center for centering cards

Tip: Some demos are best viewed in a full browser window. Click “Open in a new tab” below for the full experience.

Open in a new tab · View source on GitHub

Chrome-Style Tabs

Demonstrates the notch element in “reverse” mode: instead of a dropdown hanging BELOW a bar with concave top corners, Chrome-style tabs sit ABOVE a toolbar with concave BOTTOM corners. The concave curves flare outward past the tab’s box and visually merge with the toolbar beneath, giving the active tab its signature smooth connection to the toolbar edge.

Tip: Some demos are best viewed in a full browser window. Click “Open in a new tab” below for the full experience.

Open in a new tab · View source on GitHub

blinc_cn Components

Showcases all available blinc_cn components in a scrollable grid layout.

Tip: Some demos are best viewed in a full browser window. Click “Open in a new tab” below for the full experience.

Open in a new tab · View source on GitHub

Code Element

Demonstrates both read-only code display and editable code editor.

Features demonstrated:

  • Syntax highlighting with built-in Rust and JSON highlighters
  • Line numbers in the gutter
  • Editable code editor with Stateful incremental updates
  • Cursor, selection, clipboard (Cmd+C/X/V), undo/redo (Cmd+Z)
  • Word navigation (Cmd+Left/Right), select all (Cmd+A)
  • Vertical scrolling in the editor

Tip: Some demos are best viewed in a full browser window. Click “Open in a new tab” below for the full experience.

Open in a new tab · View source on GitHub

Complex SVG

Displays an SVG at various sizes to test rasterization quality, anti-aliasing, and HiDPI scaling.

Tip: Some demos are best viewed in a full browser window. Click “Open in a new tab” below for the full experience.

Open in a new tab · View source on GitHub

CSS Debug

Tests three known CSS issues:

  1. var() not picking up values from :root
  2. width/height percentage not working
  3. Text not inheriting color from parent div

Tip: Some demos are best viewed in a full browser window. Click “Open in a new tab” below for the full experience.

Open in a new tab · View source on GitHub

CSS Visual Features

Showcases newly added CSS visual features:

  • mix-blend-mode: Blend overlapping elements (multiply, screen, overlay, etc.)
  • pointer-events: Control click-through behavior
  • cursor: CSS cursor style on hover
  • text-decoration: Underline, line-through with color and thickness
  • text-overflow: Ellipsis truncation with white-space: nowrap

Tip: Some demos are best viewed in a full browser window. Click “Open in a new tab” below for the full experience.

Open in a new tab · View source on GitHub

Layer Effects

Showcases GPU-accelerated layer effects including:

  • Blur (element blur, not backdrop blur)
  • Drop shadows
  • Glow effects
  • Color matrix transforms (grayscale, sepia, saturation, brightness, contrast)

Tip: Some demos are best viewed in a full browser window. Click “Open in a new tab” below for the full experience.

Open in a new tab · View source on GitHub

Emoji and HTML Entities

This example demonstrates:

  • HTML entity decoding in text() elements
  • Emoji rendering with system fonts
  • ASCII special characters
  • Unicode symbols

Tip: Some demos are best viewed in a full browser window. Click “Open in a new tab” below for the full experience.

Open in a new tab · View source on GitHub

@flow Shader

Demonstrates the @flow DAG-based shader system. Custom GPU fragment shaders are defined in CSS via @flow blocks and applied to elements with flow: <name>.

Tip: Some demos are best viewed in a full browser window. Click “Open in a new tab” below for the full experience.

Open in a new tab · View source on GitHub

Fluid Surface

Combines @flow GPU shaders with pointer-query CSS-driven interaction. A central card renders a pointer-reactive fluid shader, while surrounding labels respond to cursor proximity via calc(env(pointer-distance)).

Tip: Some demos are best viewed in a full browser window. Click “Open in a new tab” below for the full experience.

Open in a new tab · View source on GitHub

Skeleton animation with glTF + blinc_canvas_kit.

Loads Sketchfab’s buster_drone (39 meshes, 92 nodes, one 25-second Start_Liftoff clip), runs the clip through blinc_skeleton each frame, and renders the result with SceneKit3D. Asset load is non-blocking: the UI paints a loading overlay while a background thread parses the glTF, then flips a scene_ready signal that the overlay’s Stateful subtree dismisses itself on.

The model is “Buster Drone” by LaVADraGoN (https://sketchfab.com/3d-models/buster-drone-294e79652f494130ad2ab00a13fdbafd), licensed CC-BY-4.0 (http://creativecommons.org/licenses/by/4.0/). Full attribution alongside the asset in assets/3d/buster_drone/license.txt.

Controls:

  • Drag: orbit
  • Scroll: zoom
  • Space: pause / resume
  • R: reset clip time
  • Left / Right: scrub ±1 frame
cargo run -p blinc_app_examples --example gltf_animation_demo \
    --features windowed --release

Tip: Some demos are best viewed in a full browser window. Click “Open in a new tab” below for the full experience.

Open in a new tab · View source on GitHub

Image CSS Styling

Demonstrates CSS properties that work on images via stylesheets:

  • opacity, border-radius, border, box-shadow
  • transform (rotate, scale, translate) via parent divs
  • CSS transitions and hover effects on image containers
  • CSS filters (grayscale, sepia, invert, brightness, contrast, saturate, hue-rotate)

Images are wrapped in divs with IDs for CSS targeting, since Image elements inherit CSS properties from their parent container’s RenderProps.

Tip: Some demos are best viewed in a full browser window. Click “Open in a new tab” below for the full experience.

Open in a new tab · View source on GitHub

Image Layer Test

Tests the rendering order of images vs primitives (paths, backgrounds). This helps debug z-order issues where images may render above/below other elements.

Solution for rendering elements ON TOP of images: Use .foreground() on any element that needs to render above images. The render order is: Background primitives → Images → Foreground primitives

Tip: Some demos are best viewed in a full browser window. Click “Open in a new tab” below for the full experience.

Open in a new tab · View source on GitHub

Keyframe Animation Canvas

Demonstrates keyframe animations with the canvas element for:

  • Spinning loader with rotation keyframes
  • Pulsing dots animation
  • Progress bar with eased fill
  • Bouncing ball animation

Tip: Some demos are best viewed in a full browser window. Click “Open in a new tab” below for the full experience.

Open in a new tab · View source on GitHub

Markdown Editor

A split-view markdown editor with:

  • TextArea on the left for writing markdown source
  • Scroll container on the right for live preview

Tip: Some demos are best viewed in a full browser window. Click “Open in a new tab” below for the full experience.

Open in a new tab · View source on GitHub

3D Mesh Demo — renders the Khronos glTF DamagedHelmet sample model

Demonstrates:

  • blinc_canvas_kit::SceneKit3D — orbit camera + light rig wrapped around a canvas element, with drag/scroll input wired for free.
  • DrawContext::draw_mesh_data — the direct-render mesh path. The canvas closure just calls ctx.draw_mesh_data(&mesh, transform); everything behind that (camera capture, pending-mesh queue, GpuPaintContext → GpuRenderer dispatch, PBR shading) is plumbing.
  • Inline glTF loading — no external gltf crate dep. The sample model has a fixed layout (single mesh, single primitive, packed f32 attributes at known bufferView offsets, u16 indices), so parsing is a handful of offset reads plus a blinc_image::ImageData call for the albedo texture.
  • Non-blocking asset loading. On desktop the mesh + HDR decode is cheap and runs synchronously; on wasm the WebAssetLoader preload is background-spawned by the wrapper, so build_ui returns before any asset is cached. A spawn_local polling loop waits for the preload, then populates a shared slot that the Stateful viewport wrapper swaps the loading overlay out for.

Tip: Some demos are best viewed in a full browser window. Click “Open in a new tab” below for the full experience.

Open in a new tab · View source on GitHub

Motion Demo

This example is auto-generated from the cross-target source in examples/blinc_app_examples/examples/. See the linked source file for the full details.

Tip: Some demos are best viewed in a full browser window. Click “Open in a new tab” below for the full experience.

Open in a new tab · View source on GitHub

Music Player Glass Card

Recreates an iOS-style “Now Playing” music player card with liquid glass morphism effect (refracted bevel borders). All visual styling is driven by CSS via ctx.add_css().

Tip: Some demos are best viewed in a full browser window. Click “Open in a new tab” below for the full experience.

Open in a new tab · View source on GitHub

Notch Menu Bar

Demonstrates a macOS-style menu bar with a notched dropdown that slides horizontally between icons. The dropdown maintains a seamless visual connection to the menu bar via concave curves.

Tip: Some demos are best viewed in a full browser window. Click “Open in a new tab” below for the full experience.

Open in a new tab · View source on GitHub

Overflow Fade

Demonstrates the overflow-fade CSS property which applies smooth alpha fading at overflow clip edges instead of hard clipping.

Supports:

  • Uniform fade: overflow-fade: 24px (all edges)
  • Vertical/horizontal: overflow-fade: 24px 0px (top/bottom only)
  • Per-edge: overflow-fade: 24px 0px 24px 0px
  • CSS transitions and @keyframes animation
  • Works with scroll containers

Tip: Some demos are best viewed in a full browser window. Click “Open in a new tab” below for the full experience.

Open in a new tab · View source on GitHub

Overlay System

This example demonstrates the overlay infrastructure for modals, dialogs, context menus, and toast notifications.

Features demonstrated:

  • Modal dialogs with backdrop
  • Toast notifications in corners
  • Context menus at cursor position
  • Overlay manager accessed via ctx.overlay_manager()

Tip: Some demos are best viewed in a full browser window. Click “Open in a new tab” below for the full experience.

Open in a new tab · View source on GitHub

Pointer Query

Demonstrates the CSS-driven continuous pointer query system. All pointer-reactive effects are defined purely in CSS using calc(env(pointer-*)) expressions — no Rust pointer reads needed.

The pointer query system binds cursor position to ANY numerical CSS property: opacity, corner-radius, border-width, rotate, and more.

CSS properties used: pointer-space: self; — enables pointer tracking pointer-origin: center; — coordinate origin pointer-range: -1.0 1.0; — output range pointer-smoothing: 0.08; — exponential smoothing opacity: calc(env(pointer-)); — hover fade border-radius: calc(env(pointer-)); — dynamic corners border-width: calc(env(pointer-)); — dynamic borders rotate: calc(env(pointer-)); — subtle rotation

Tip: Some demos are best viewed in a full browser window. Click “Open in a new tab” below for the full experience.

Open in a new tab · View source on GitHub

Rich Text Element

This example demonstrates the rich_text element for inline text formatting with HTML-like tags.

Features demonstrated:

  • HTML-like inline formatting (, , , )
  • Nested tags
  • Inline colors with
  • Links with
  • Range-based programmatic styling API
  • Entity decoding (<, >, &, etc.)

Tip: Some demos are best viewed in a full browser window. Click “Open in a new tab” below for the full experience.

Open in a new tab · View source on GitHub

Rich Text Editor

Full editable rich text editor with cursor, selection, and inline formatting. Demonstrates every block kind and inline mark currently supported by the editor model:

  • Headings (H1–H3)
  • Paragraphs with mixed bold / italic / underline / strikethrough / inline-code / colored / linked spans
  • Bullet and numbered lists, including nested lists via indent
  • Block quote
  • Horizontal divider

Tip: Some demos are best viewed in a full browser window. Click “Open in a new tab” below for the full experience.

Open in a new tab · View source on GitHub

Scroll Container

This example demonstrates the scroll widget with webkit-style bounce physics, glass clipping, and scroll event handling.

Features demonstrated:

  • scroll() container with bounce physics
  • Glass elements clipping properly inside scroll
  • Scroll event handling with delta reporting
  • Spring animation for edge bounce
  • Toggle between vertical and horizontal scroll directions
  • Using reactive state system (ctx.use_state) for state persistence

Tip: Some demos are best viewed in a full browser window. Click “Open in a new tab” below for the full experience.

Open in a new tab · View source on GitHub

Semantic @flow

Demonstrates the semantic step/chain/use system for @flow shaders. Uses step, chain, and raw node syntax together to create a layered noise visualization with pointer-reactive color ramping.

The fourth card (“Plasma”) uses the flow! macro to define a flow shader entirely in Rust — no CSS strings needed.

Tip: Some demos are best viewed in a full browser window. Click “Open in a new tab” below for the full experience.

Open in a new tab · View source on GitHub

Sortable

Demonstrates drag-based interactions using FSM-driven stateful containers:

  • Sortable list: drag items to reorder
  • Swipe to delete: horizontal drag to dismiss items (stack overlay)
  • Sortable grid: 3x3 drag-to-reorder grid

Tip: Some demos are best viewed in a full browser window. Click “Open in a new tab” below for the full experience.

Open in a new tab · View source on GitHub

Stateful API

This example demonstrates the new stateful::() API with:

  • ctx.event() - Access triggering event in state callbacks
  • ctx.use_signal() - Scoped signals for local state
  • ctx.use_animated_value() - Spring-animated values

Tip: Some demos are best viewed in a full browser window. Click “Open in a new tab” below for the full experience.

Open in a new tab · View source on GitHub

End-to-end 3D demo wiring Blinc’s SceneKit3D renderer up to

  • blinc_canvas_kit::SceneKit3D — the camera + light + mesh dispatch front-end used by any Blinc app that wants to drop 3D content into a canvas(). Same primitive demos use for a single spinning cube scale up to a full character rig unchanged.
  • blinc_gltf — glTF 2.0 loader. Parses the file tree once at startup into a GltfScene (meshes, nodes, skeletons, animation clips) that the demo holds behind an Arc<Mutex<>> and borrows per frame.
  • blinc_skeleton — runtime poser. animate_scene_nodes samples the clip’s TRS channels into the live node tree; scene_skinning_data walks the posed tree to build the joint matrices the mesh shader consumes; animate_scene_morph_weights drives per-node blend-shape weights for facial expression.

The asset is “The Strangler” by Jungle Jim (CC-BY-4.0; https://sketchfab.com/3d-models/the-strangler-06d56efabf7445e89bb1bf41a99d08cc), shipped in the repo for offline reproducibility. Full attribution lives alongside the asset in examples/.../assets/3d/the_strangler/license.txt.

Per-frame flow:

  1. animate_scene_nodes(&mut scene, anim, t) — writes sampled TRS onto scene nodes
  2. scene_skinning_data(&scene, &skeleton) — returns SkinningData (joint world matrices × inverse-bind)
  3. animate_scene_morph_weights(anim, t) — returns a HashMap<node_index, Vec<f32>> of current weights
  4. For each drawable node: shallow-clone its MeshData (Arc<Vec<_>> inners → refcount bumps, no vertex copy), stamp the frame’s skinning + morph_weights, dispatch via DrawContext::draw_mesh_data.

Ordering (OPAQUE before BLEND) is enforced framework-side in blinc_app::dispatch_pending_meshes, so the demo submits in scene-graph order without its own sort.

cargo run -p blinc_app_examples --example strangler_demo \
    --features windowed --release

Tip: Some demos are best viewed in a full browser window. Click “Open in a new tab” below for the full experience.

Open in a new tab · View source on GitHub

Unified Styling API

Demonstrates all styling approaches in Blinc:

  • css! macro: CSS-like syntax with hyphenated property names
  • style! macro: Rust-friendly syntax with underscored names
  • ElementStyle builder: Programmatic style construction
  • CSS Parser: Runtime CSS string parsing

All approaches produce ElementStyle - a unified schema for visual properties.

Tip: Some demos are best viewed in a full browser window. Click “Open in a new tab” below for the full experience.

Open in a new tab · View source on GitHub

SVG Animation

Demonstrates SVG animation capabilities:

  • CSS transforms on SVG elements (rotate, scale)
  • Fill/stroke color animation via @keyframes
  • stroke-dasharray/dashoffset line-drawing effect
  • Path morphing (d-attribute animation)
  • Hamburger Menus: 9 food-themed icons (morph, dash, pulse, orbit)

Tip: Some demos are best viewed in a full browser window. Click “Open in a new tab” below for the full experience.

Open in a new tab · View source on GitHub

Table Builder

This example demonstrates the TableBuilder API for declarative table creation.

Tip: Some demos are best viewed in a full browser window. Click “Open in a new tab” below for the full experience.

Open in a new tab · View source on GitHub

Tabler Icons

Showcases outline and filled icons from the blinc_tabler_icons crate.

Tip: Some demos are best viewed in a full browser window. Click “Open in a new tab” below for the full experience.

Open in a new tab · View source on GitHub

Minimal text positioning test

Tests that text is correctly centered within parent containers.

Tip: Some demos are best viewed in a full browser window. Click “Open in a new tab” below for the full experience.

Open in a new tab · View source on GitHub

Text Input Widgets

Demonstrates ready-to-use text input and text area elements using the layout API.

Tip: Some demos are best viewed in a full browser window. Click “Open in a new tab” below for the full experience.

Open in a new tab · View source on GitHub

KHR_texture_transform

Loads Poly Haven’s marble_cliff_02 asset (CC0) — a displaced rock chunk with a tiling PBR material — and showcases the KHR_texture_transform glTF extension support added in blinc_core::TextureTransform + blinc_gpu::mesh_pipeline + blinc_gltf::parse_material.

The asset’s glTF JSON was patched to include "extensions": { "KHR_texture_transform": { "scale": [3, 3] } } on every texture binding, so parse_material reads a 3× tile transform and the shader multiplies UVs accordingly before every sample. Press T to toggle the transform off for a side-by-side comparison — toggling swaps between the parsed Material and a clone with texture_transform: None, exercising the shader’s identity path.

Tip: Some demos are best viewed in a full browser window. Click “Open in a new tab” below for the full experience.

Open in a new tab · View source on GitHub

Theme System

This example demonstrates the Blinc theming system capabilities:

  • Light/dark mode switching with smooth transitions
  • Semantic color tokens (primary, secondary, success, error, etc.)
  • Typography tokens (font sizes, weights)
  • Spacing tokens (4px-based scale)
  • Border radius tokens
  • Platform-native theme detection

Tip: Some demos are best viewed in a full browser window. Click “Open in a new tab” below for the full experience.

Open in a new tab · View source on GitHub

Timeline Animation

This example demonstrates timeline-based animations using the stateful API:

  • Ping-pong animations using use_keyframes with fluent builder
  • Multiple animated values with staggered delays
  • Continuous looping animations with easing

Tip: Some demos are best viewed in a full browser window. Click “Open in a new tab” below for the full experience.

Open in a new tab · View source on GitHub

Typography

This example demonstrates typography helpers:

  • Headings: h1-h6, heading()
  • Inline text: b, span, small, label, muted, p, caption, inline_code
  • Font families: system, monospace, serif, sans_serif, custom fonts

For table examples, see table_demo.rs

Tip: Some demos are best viewed in a full browser window. Click “Open in a new tab” below for the full experience.

Open in a new tab · View source on GitHub

Video Player

Demonstrates the video_player widget with blinc_media::VideoPlayer instance and controls.

Tip: Some demos are best viewed in a full browser window. Click “Open in a new tab” below for the full experience.

Open in a new tab · View source on GitHub

Wet Glass

Procedural wet-window effect with real light refraction through water drops. Uses sample_scene() to read the background and distort it through procedural Worley-noise drops, streaks, and condensation fog.

Tip: Some demos are best viewed in a full browser window. Click “Open in a new tab” below for the full experience.

Open in a new tab · View source on GitHub

Windowed Application

This example demonstrates how to create a windowed Blinc application using the platform abstraction layer with a colorful music-player style background.

Features demonstrated:

  • Stateful<S> with on_state callback for reactive state management
  • Window resize/focus events via context properties
  • Image element with hover glow effect

Tip: Some demos are best viewed in a full browser window. Click “Open in a new tab” below for the full experience.

Open in a new tab · View source on GitHub

Web Development

Blinc compiles to wasm32-unknown-unknown and runs inside a <canvas> element via wgpu’s WebGPU backend (with WebGL2 fallback). The same Rust UI code that runs on desktop and mobile runs in the browser, with no source-level changes.

The web target is Tier 2 / preview: it ships, has runnable examples, and exercises the same render / event / state pipelines as the native runners — but a few platform-specific bits (touch input, IME, file dialogs, multi-canvas, accessibility) are deliberately out of scope for the initial cut.

Cross-platform architecture

┌─────────────────────────────────────────────────────────────┐
│                      Your Blinc App                          │
│         (Shared Rust UI code, state, animations)             │
└─────────────────────────────┬───────────────────────────────┘
                              │
       ┌──────┬───────────────┼───────────────┬──────────┐
       │      │               │               │          │
   ┌───▼──┐ ┌─▼──────┐  ┌─────▼──────┐ ┌─────▼─────┐ ┌──▼──┐
   │macOS │ │Windows │  │  Android   │ │    iOS    │ │ Web │
   │ Metal│ │DX12/Vk │  │  Vulkan    │ │   Metal   │ │WebGPU│
   └──────┘ └────────┘  └────────────┘ └───────────┘ └─────┘

The web runner (crates/blinc_app/src/web.rs) is a sibling of the desktop / Android / iOS runners. It owns the same 5-phase frame loop:

  1. Tick scroll physicstree.tick_scroll_physics(now_ms) advances active scroll deceleration and bounce springs
  2. Detect rebuild triggers — polls tree.needs_rebuild(), take_needs_rebuild(), and the reactive dirty flag
  3. Drain pending Stateful updates — applies queued render-prop and subtree changes from State::set / State::update
  4. Rebuild or incrementally updatetree.incremental_update(&element) for normal frames, full rebuild on resize
  5. Rendersurface.get_current_texture()BlincApp::render_tree(...)frame.present()

The driver is a requestAnimationFrame chain that fires every browser frame.

Key features

  • GPU rendering via WebGPU — same SDF / batching / glass / 3D pipelines as desktop
  • Mouse + wheel + keyboard input — routed through the same EventRouter desktop uses
  • Drag gestures — DRAG / DRAG_END events with deltas, same Stateful machinery
  • Reactive stateBlincContextState, State::set, Stateful::on_state all work unchanged
  • Animations — spring physics, keyframes, motion containers all tick from requestAnimationFrame
  • Asset fetchWebAssetLoader::fetch_bytes for runtime asset loading instead of bundling
  • Async setupWebApp::run_with_async_setup for fonts, CSS, or anything else that needs to .await before the first frame

Browser support

BrowserStatusNotes
Chrome / Chromium ≥ 113SupportedWebGPU enabled by default
Edge ≥ 113SupportedSame Chromium engine
Safari Technology Preview (pre Tahoe)Partial (flagged)Develop → Feature Flags → WebGPU . Pre-sequoia does not support Vertex Storage
Safari stable (Tahoe)SupportedWebGPU enabled by default
Firefox (≥ 141 on windows, ≥ 145 on MacOs)SupportedWebGPU enabled by default

The runtime falls back to WebGL2 where WebGPU is unavailable, but storage-buffer-dependent pipelines need WebGPU — specifically the SDF aux buffer and any future compute shaders. Plain rendering, text, and SVG work on WebGL2; advanced 3D and particles do not.

Project structure

A typical Blinc web project looks like this:

my-web-app/
├── Cargo.toml           # crate-type = ["cdylib", "rlib"]
├── src/
│   └── lib.rs           # #[wasm_bindgen(start)] + build_ui
├── fonts/               # Optional: fonts to bundle or fetch
│   └── Inter.ttf
├── index.html           # canvas + WebGPU/WebGL2 probe + ES module loader
├── pkg/                 # Generated by `wasm-pack build` (gitignored)
│   ├── my_web_app.js
│   └── my_web_app_bg.wasm
└── serve.sh             # Static file server (python3 / ruby / npx)

The runnable examples/web_hello, examples/web_scroll, examples/web_drag, and examples/web_assets follow this exact layout — copy any of them as a starting point.

What’s deliberately different from desktop

scroll() defaults to bounce-disabled

The native scroll() widget uses spring-bounce at edges. On wasm32, the default is flipped: scroll() returns a no-bounce config. DOM wheel events have no reliable “gesture ended” phase, and macOS layers ~800ms of OS-level momentum-scroll events on top of the user’s gesture — every workaround for “when did the user finish?” produces either a perceptible bounce lag or a wobble as the spring restarts each time the OS momentum re-overscrolls. Native HTML scrolling has no rubber-band either, except at the page level in iOS / macOS Safari, and that’s owned by the OS, not by anything inside a <canvas>.

Apps that explicitly want bounce on web can opt in via Scroll::with_config(ScrollConfig::default()) or supply their own SharedScrollPhysics to Scroll::with_physics.

Async clipboard

web_sys::Clipboard::write_text / read_text are async-only. Text-editor widgets’ Cmd+C / Cmd+V keybinds still trigger on the keypress, but the clipboard write is fire-and-forget and the read can’t be await-ed inside a synchronous handler.

Single canvas

WebApp::run takes a single canvas ID. Multi-canvas / multi-view setups (e.g. an in-page editor preview alongside the main app) are architecturally supported via the shared ElementRegistry, but no WebApp::run_multi API has shipped yet.

What’s missing (Tier 2 gaps)

FeatureStatusNotes
Mouse + wheel + keyboard inputRoutes through EventRouter
Drag gesturesDRAG / DRAG_END events with deltas
Touch inputPendingDOM touch* events need conversion to InputEvent::Touch
IME compositionPendingcompositionstart / update / endEventRouter::on_text
File dialogsPendingrfd doesn’t compile on wasm32; needs <input type="file"> bridge
System tray / notifications / global hotkeysWon’t fixBrowser sandbox doesn’t expose these
localStorage window-state persistencePendingTrivial follow-up using web-sys::Storage
Service worker / offline assetsOut of scopeApp-level concern
Multi-canvas / multi-viewPendingArchitecture supports it; no public API yet
A11y (ARIA roles, screen reader)PendingLarger architecture discussion — needs DOM mirror or accesskit-html

Next steps

  • Setup & Build — Cargo.toml, wasm-pack, the index.html HTML+JS scaffold
  • Examples — walkthrough of the four runnable web examples
  • Fonts & Assets — bundled vs fetched fonts, the WebAssetLoader API

Setup & Build

This page walks through the minimum scaffolding to get a Blinc app running in the browser. The four runnable examples in the repo (examples/web_hello, web_scroll, web_drag, web_assets) all follow this exact layout — copy any of them as a starting point.

Toolchain

You need three things on top of a normal Rust toolchain:

  1. The wasm32-unknown-unknown target

    rustup target add wasm32-unknown-unknown
    
  2. wasm-pack — drives the wasm build, runs wasm-bindgen, and post-processes the output via wasm-opt

    cargo install wasm-pack
    
  3. A static file serverpython3 -m http.server is fine. Browsers refuse to import wasm modules from file:// URLs, so even a “static” example has to be served over HTTP.

The repo’s example serve.sh scripts auto-pick the first available server (python3pythonrubynpx http-server).

Cargo.toml

[package]
name = "my_web_app"
version = "0.1.0"
edition = "2021"

[lib]
# `cdylib` is what wasm-pack needs to emit a `.wasm` artifact + JS shim.
# `rlib` keeps `cargo check` happy on non-wasm targets.
crate-type = ["cdylib", "rlib"]

# wasm-pack invokes Binaryen's `wasm-opt` as a post-processing step.
# Nightly rustc emits bulk-memory and reference-type ops by default,
# but the wasm-opt bundled with wasm-pack 0.13 needs the corresponding
# feature flags or it errors with "Bulk memory operations require bulk
# memory [--enable-bulk-memory]". Pass them through explicitly.
[package.metadata.wasm-pack.profile.release]
wasm-opt = ['-O', '--all-features']

[package.metadata.wasm-pack.profile.dev]
wasm-opt = false

# Strictly a wasm32 example. Native builds of this crate are
# meaningless — there's no entry point that does anything outside
# the browser. The target gate keeps `cargo build --workspace` from
# trying to link an empty cdylib on macOS / Linux.
[target.'cfg(target_arch = "wasm32")'.dependencies]
blinc_app = { version = "0.4", default-features = false, features = ["web"] }
blinc_layout = { version = "0.4" }
blinc_core = { version = "0.4" }
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
web-sys = { version = "0.3", features = ["console"] }
console_error_panic_hook = "0.1"
tracing = "0.1"
tracing-wasm = "0.2"

The critical bits:

  • crate-type = ["cdylib", "rlib"] — wasm-pack needs cdylib to emit the .wasm artifact. The rlib is optional but lets cargo check work without --target wasm32-unknown-unknown.
  • features = ["web"] on blinc_app — gates in WebApp, WebApp::run, the requestAnimationFrame driver, and the wasm32-only event listeners. Without default-features = false, you’d accidentally pull in winit and the desktop platform crates.
  • [target.'cfg(target_arch = "wasm32")'.dependencies] — every Blinc dep is target-gated so a desktop cargo build --workspace doesn’t try to compile the web example.

src/lib.rs

#![allow(unused)]
#![cfg(target_arch = "wasm32")]

fn main() {
use blinc_app::web::WebApp;
use blinc_app::windowed::WindowedContext;
use blinc_core::Color;
use blinc_layout::div::{div, Div};
use blinc_layout::text::text;
use wasm_bindgen::prelude::*;

const FONT: &[u8] = include_bytes!("../fonts/Inter.ttf");

/// wasm-bindgen entry point. The `start` attribute makes this run
/// automatically when the browser loads the generated `.js` shim.
#[wasm_bindgen(start)]
pub fn _start() {
    // Install the panic hook so any Rust panic shows up in the
    // browser console with a stack trace instead of a useless
    // `RuntimeError: unreachable executed`.
    console_error_panic_hook::set_once();

    // Bridge `tracing::*` macros into the browser DevTools console.
    // INFO level keeps the per-frame DEBUG lines from the renderer
    // out of the console — at 60fps those drown the JS thread.
    tracing_wasm::set_as_global_default_with_config(
        tracing_wasm::WASMLayerConfigBuilder::new()
            .set_max_level(tracing::Level::INFO)
            .build(),
    );

    // `WebApp::run` is `async`, but `#[wasm_bindgen(start)]` can't
    // return a future. Spawn it on the wasm-bindgen-futures executor
    // instead.
    wasm_bindgen_futures::spawn_local(async {
        let result = WebApp::run_with_setup(
            "blinc-canvas",
            // Setup callback runs once between init and the first
            // frame. Use it to register fonts (required — the wasm32
            // init path skips system font discovery, so the registry
            // starts empty), CSS, and any one-shot config.
            |app| {
                app.load_font_data(FONT.to_vec());
            },
            build_ui,
        )
        .await;

        if let Err(e) = result {
            web_sys::console::error_1(
                &format!("WebApp::run failed: {e}").into(),
            );
        }
    });
}

/// User UI builder. Re-invoked by the runner whenever a rebuild is
/// requested.
fn build_ui(_ctx: &mut WindowedContext) -> Div {
    div()
        .w_full()
        .h_full()
        .bg(Color::rgba(0.07, 0.07, 0.10, 1.0))
        .items_center()
        .justify_center()
        .child(
            text("Hello, WebGPU!")
                .size(32.0)
                .color(Color::rgba(0.92, 0.92, 0.95, 1.0)),
        )
}
}

The two non-obvious bits:

  • #![cfg(target_arch = "wasm32")] at the top — the rest of the file uses wasm-bindgen and web-sys, which only exist on wasm32. The cfg attribute makes the whole module a no-op when someone runs cargo check from a desktop checkout.
  • load_font_data(FONT.to_vec()) inside the setup closure — required. The wasm32 init path deliberately skips system font discovery (no filesystem), so without at least one explicitly registered font every text element renders as nothing. See Fonts & Assets for the alternative fetch-based pattern.

index.html

<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>My Blinc Web App</title>
    <style>
      html, body { margin: 0; padding: 0; height: 100%; background: #0d0d12; }
      body { display: flex; flex-direction: column; }
      #blinc-canvas { display: block; width: 100vw; height: 100vh; }
      #unsupported {
        display: none; position: absolute; inset: 0;
        align-items: center; justify-content: center;
        flex-direction: column; gap: 12px;
        font-family: system-ui, sans-serif; color: #ededf0;
        text-align: center; padding: 24px;
      }
      .no-webgpu #blinc-canvas { display: none; }
      .no-webgpu #unsupported { display: flex; }
    </style>
  </head>
  <body>
    <canvas id="blinc-canvas"></canvas>

    <div id="unsupported">
      <strong>WebGPU not available</strong>
      <span>This app requires Chrome / Edge 113+ or a browser with WebGPU enabled.</span>
    </div>

    <script type="module">
      // CRITICAL: probe via a *throwaway* canvas, never the canvas
      // we hand to wgpu. Calling `getContext("webgl2")` on the live
      // canvas locks its context type forever and breaks wgpu's
      // surface creation with "canvas already in use".
      const hasWebGPU = "gpu" in navigator;
      const probeCanvas = document.createElement("canvas");
      const hasWebGL2 = !!probeCanvas.getContext("webgl2");

      if (!hasWebGPU && !hasWebGL2) {
        document.body.classList.add("no-webgpu");
      } else {
        // wasm-pack `--target web` emits an ES module loader at
        // `pkg/<crate>.js` that exports the wasm `init` function.
        // Importing it kicks off the loader; the `start` function
        // fires automatically.
        const { default: init } = await import("./pkg/my_web_app.js");
        await init();
      }
    </script>
  </body>
</html>

The throwaway canvas probe is mandatory. The W3C HTML canvas-context spec says calling getContext("webgl2") on a canvas locks its context type to webgl2 forever — every subsequent getContext("webgpu") on the same element returns null, and wgpu’s surface creation fails with “canvas already in use”.

Build commands

# Development build (no wasm-opt, fast iteration)
wasm-pack build --target web --dev

# Release build (wasm-opt -O, smaller and faster)
wasm-pack build --target web --release

# Then serve `pkg/` and `index.html` together
python3 -m http.server 8000
# open http://localhost:8000/

wasm-pack build produces a pkg/ directory containing:

  • <crate>.js — JS shim (~100 KB) that calls wasm-bindgen-generated bindings
  • <crate>_bg.wasm — the actual wasm artifact (typically 6-8 MB pre-strip, smaller after wasm-opt)
  • <crate>.d.ts — TypeScript bindings (optional)
  • package.json — npm metadata (optional)

pkg/ is regenerated on every build, so it should be in .gitignore:

# wasm-pack output — regenerated by `wasm-pack build --target web --release`.
pkg/

Bundle size

A minimal Blinc app is ~6-8 MB pre-strip. Where the bytes go:

  • Renderer + WGSL shaders + wgpu — ~3 MB
  • rustybuzz / unicode-bidi / unicode-linebreak (text shaping) — ~600 KB
  • resvg / tiny-skia (SVG rasterization) — ~700 KB
  • Layout (taffy + flexbox) — ~200 KB
  • Reactive graph + state hooks + Stateful machinery — ~400 KB
  • Bundled font (if any) — ~750 KB per typical TTF

For tighter bundles, see the Fonts & Assets chapter on fetching fonts at runtime instead of bundling them — the web_assets example is 612 KB smaller than web_hello purely because Arial is fetched on first load instead of baked into the wasm.

Next

  • Examples — walkthrough of the four runnable web examples
  • Fonts & Assets — bundled vs fetched fonts, WebAssetLoader API

Examples

The repo ships four runnable web examples under examples/. Each is a single-file lib.rs under 250 lines, with the same index.html + serve.sh scaffolding from the Setup chapter. They’re the canonical reference for every pattern in this book — when in doubt, copy from one of these.

ExampleWhat it demonstratesLines
web_helloMinimum: canvas + bundled font + one Div~130
web_scrollWheel input → scroll widget → physics tick~200
web_dragMouse drag → Stateful + State::set → visual update~245
web_assetsFetch fonts at runtime via WebAssetLoader~135

web_hello — the smallest possible app

The “Hello, WebGPU!” canonical example. Centered text on a dark background, no input handlers, no animations, no state. If this draws on a real canvas in a real browser, the entire wgpu / wasm-bindgen / requestAnimationFrame / WebApp::run pipeline is alive end-to-end.

#![allow(unused)]
fn main() {
use blinc_app::web::WebApp;
use blinc_layout::div::{div, Div};
use blinc_layout::text::text;
use blinc_core::Color;
use wasm_bindgen::prelude::*;

const ARIAL: &[u8] = include_bytes!("../fonts/Arial.ttf");

#[wasm_bindgen(start)]
pub fn _start() {
    console_error_panic_hook::set_once();

    wasm_bindgen_futures::spawn_local(async {
        WebApp::run_with_setup(
            "blinc-canvas",
            |app| { app.load_font_data(ARIAL.to_vec()); },
            build_ui,
        ).await.unwrap();
    });
}

fn build_ui(_ctx: &mut blinc_app::windowed::WindowedContext) -> Div {
    div()
        .w_full().h_full()
        .bg(Color::rgba(0.07, 0.07, 0.10, 1.0))
        .items_center().justify_center()
        .child(
            text("Hello, WebGPU!")
                .size(32.0)
                .color(Color::WHITE),
        )
}
}

Read the full example: examples/web_hello/src/lib.rs.

web_scroll — wheel input + scroll physics

A vertical list of 24 cards inside a scroll() container. Demonstrates:

  • Wheel input routes through EventRouter::on_scroll_nested and is dispatched via RenderTree::dispatch_scroll_chain (which walks the chain of scroll containers from leaf to root for nested-scroll consumption)
  • The scroll widget’s per-frame physics tick (tree.tick_scroll_physics(now_ms)) runs every rAF frame, advancing any active deceleration
  • Click events also fire on cards via on_click handlers
  • The no-bounce default for wasm32 — scroll() returns a ScrollConfig::no_bounce() config because DOM wheel events have no “gesture ended” phase to drive bounce-back from. See the Overview for the rationale.
#![allow(unused)]
fn main() {
const CARD_COUNT: usize = 24;

let mut content = div().w_full().flex_col().p_px(20.0).gap_px(12.0);
for idx in 0..CARD_COUNT {
    let label = format!("Card {}", idx + 1);
    let card_index = idx + 1;
    content = content.child(
        div()
            .w_full().h_fit()
            .bg(Color::rgba(0.16, 0.16, 0.21, 1.0))
            .rounded(12.0).p_px(16.0)
            .child(text(&label).size(20.0).color(Color::WHITE))
            .on_click(move |_| {
                web_sys::console::log_1(
                    &format!("clicked card #{card_index}").into(),
                );
            }),
    );
}

div()
    .w_full().h_full()
    .bg(Color::rgba(0.07, 0.07, 0.10, 1.0))
    .child(
        scroll()
            .w_full()
            .h(ctx.height - 96.0)
            .child(content)
            .on_scroll(|e| {
                tracing::info!(
                    "scroll delta=({:.1}, {:.1})",
                    e.scroll_delta_x, e.scroll_delta_y,
                );
            }),
    )
}

Full example: examples/web_scroll/src/lib.rs.

web_drag — gesture interaction with Stateful + State

A single draggable card that lifts (opacity dip + raised z-index) and follows the cursor while held, then snaps back on release. Structurally identical to the sortable_list_section in the desktop sortable_demo.rs — same DragFSM, same Stateful::on_state recipe, same handler chain, same code that runs on Android and iOS:

#![allow(unused)]
fn main() {
use blinc_layout::stateful::{stateful_with_key, StateTransitions};
use blinc_core::reactive::State;
use blinc_core::context_state::BlincContextState;
use blinc_core::events::event_types;

#[derive(Default, Clone, Copy, PartialEq, Eq, Hash, Debug)]
enum DragFSM {
    #[default]
    Idle,
    Dragging,
}

impl StateTransitions for DragFSM {
    fn on_event(&self, event: u32) -> Option<Self> {
        match (self, event) {
            (DragFSM::Idle, event_types::DRAG) => Some(DragFSM::Dragging),
            (DragFSM::Dragging, event_types::DRAG_END) => Some(DragFSM::Idle),
            (DragFSM::Dragging, event_types::POINTER_UP) => Some(DragFSM::Idle),
            _ => None,
        }
    }
}

fn draggable_card() -> Stateful<DragFSM> {
    let blinc = BlincContextState::get();
    let offset: State<(f32, f32)> =
        blinc.use_state_keyed("card_offset", || (0.0, 0.0));

    let offset_for_drag = offset.clone();
    let offset_for_end = offset.clone();

    stateful_with_key::<DragFSM>("draggable-card")
        .deps([offset.signal_id()])
        .on_state(move |ctx| {
            let (ox, oy) = offset.get();
            let dragging = matches!(ctx.state(), DragFSM::Dragging);

            let mut card = div()
                .w(220.0).h(120.0)
                .bg(Color::rgba(0.32, 0.55, 0.92, 1.0))
                .rounded(16.0)
                .items_center().justify_center()
                .child(text("Drag me").size(20.0).color(Color::WHITE));

            if dragging {
                card = card
                    .transform(Transform::translate(ox, oy))
                    .opacity(0.85)
                    .z_index(100);
            }
            card
        })
        .on_drag(move |e| {
            offset_for_drag.set((e.drag_delta_x, e.drag_delta_y));
        })
        .on_drag_end(move |_e| {
            offset_for_end.set((0.0, 0.0));
        })
}
}

The framework’s DragFSM Stateful state transitions automatically as DRAG / DRAG_END events fire — ctx.state() reads the current FSM state without you having to maintain a parallel bool. The visual offset lives in its own State<(f32, f32)> cell because it changes far more frequently than the FSM (every drag tick vs only at gesture boundaries).

Full example: examples/web_drag/src/lib.rs.

web_assets — fetched font instead of bundled

Demonstrates the WebApp::run_with_async_setup + WebAssetLoader::fetch_bytes pattern. The font isn’t bundled inside the wasm artifact; it’s fetched at runtime as a separate static asset that the browser caches independently. This example’s wasm is 612 KB smaller than web_hello purely because Arial is no longer baked into the bundle:

#![allow(unused)]
fn main() {
use blinc_app::web::WebApp;
use blinc_app::BlincError;
use blinc_platform_web::WebAssetLoader;

const FONT_URL: &str = "fonts/Arial.ttf";

#[wasm_bindgen(start)]
pub fn _start() {
    console_error_panic_hook::set_once();

    wasm_bindgen_futures::spawn_local(async {
        WebApp::run_with_async_setup(
            "blinc-canvas",
            // The `Box::pin(async move { ... })` ceremony is the
            // stable-Rust workaround for the lack of `async FnOnce`.
            // Once async closures stabilize this drops back to
            // `|app| async move { ... }`.
            |app| Box::pin(async move {
                let bytes = WebAssetLoader::fetch_bytes(FONT_URL)
                    .await
                    .map_err(|e| BlincError::Platform(e.to_string()))?;
                app.load_font_data(bytes);
                Ok(())
            }),
            build_ui,
        ).await.unwrap();
    });
}
}

See the Fonts & Assets chapter for the full picture, including the recommended bundled-fallback-then-fetched-real-font pattern for production apps.

Full example: examples/web_assets/src/lib.rs.

Running an example locally

git clone https://github.com/project-blinc/Blinc
cd Blinc/examples/web_hello
wasm-pack build --target web --release
./serve.sh
# open http://localhost:8000/

./serve.sh picks the first available static-file server on your system (python3pythonrubynpx http-server) and runs it from the example directory. If pkg/ doesn’t exist yet (i.e. you forgot to wasm-pack build first), the script exits with a hint.

Fonts & Assets

Browsers can’t hand wgpu their system fonts — those live in the compositor’s 2D pipeline, not in the WebGPU pipeline. Blinc rasterizes glyphs natively via swash, which needs the actual TTF/OTF bytes in wasm memory. The wasm32 init path deliberately skips system font discovery (no filesystem), so the font registry starts empty.

Without a registered font, every text element fails to shape glyphs and renders as nothing. Loading at least one font is mandatory.

Two patterns

Pattern 1: bundled font (include_bytes!)

The simplest option. Font bytes ship inside the wasm artifact via include_bytes!. Adds ~750 KB to the bundle per typical TTF, but the font is “available” the moment WebApp::new returns — no extra network round-trip, no fallback flicker.

#![allow(unused)]
fn main() {
use blinc_app::web::WebApp;
use wasm_bindgen::prelude::*;

const ARIAL_TTF: &[u8] = include_bytes!("../fonts/Arial.ttf");

#[wasm_bindgen(start)]
pub fn _start() {
    console_error_panic_hook::set_once();

    wasm_bindgen_futures::spawn_local(async {
        WebApp::run_with_setup(
            "blinc-canvas",
            // Sync setup callback — runs once between init and the
            // first frame. Just hands the font bytes to the registry.
            |app| {
                let faces = app.load_font_data(ARIAL_TTF.to_vec());
                tracing::info!("registered {faces} font face(s)");
            },
            build_ui,
        )
        .await
        .unwrap();
    });
}
}

load_font_data returns the number of font faces registered. Most TTFs have a single face; TTC collections have several.

This is the pattern web_hello, web_scroll, and web_drag use. It’s the right choice for prototypes, demos, and any app that ships a single small font.

Pattern 2: fetched font (run_with_async_setup)

Recommended for real apps that ship more than one font, or for any font over a few hundred KB. The font lives next to index.html as a static asset; the browser caches it independently across reloads, and the wasm artifact stays small.

#![allow(unused)]
fn main() {
use blinc_app::web::WebApp;
use blinc_app::BlincError;
use blinc_platform_web::WebAssetLoader;
use wasm_bindgen::prelude::*;

#[wasm_bindgen(start)]
pub fn _start() {
    console_error_panic_hook::set_once();

    wasm_bindgen_futures::spawn_local(async {
        WebApp::run_with_async_setup(
            "blinc-canvas",
            // The `Box::pin(async move { ... })` ceremony is the
            // stable-Rust workaround for the lack of `async FnOnce`.
            // Once async closures stabilize, this drops back to
            // `|app| async move { ... }`.
            |app| Box::pin(async move {
                let bytes = WebAssetLoader::fetch_bytes("fonts/Inter.ttf")
                    .await
                    .map_err(|e| BlincError::Platform(e.to_string()))?;
                app.load_font_data(bytes);
                Ok(())
            }),
            build_ui,
        )
        .await
        .unwrap();
    });
}
}

run_with_async_setup is the async sibling of run_with_setup. The setup closure runs once between init and the first frame. The runner awaits the returned future synchronously before installing the UI builder, so by the time the first rAF tick fires, the font is already in the registry.

WebAssetLoader::fetch_bytes is a one-shot helper that fetches a single URL and returns the raw bytes. It does not keep a copy in the loader cache — the bytes have a downstream owner (the font registry, which takes ownership in load_font_data), and caching them on the loader side too would just double the memory.

This is the pattern the web_assets example uses. The wasm artifact is 612 KB smaller than web_hello purely because Arial is no longer baked into the bundle.

The first-frame timing of pattern 2 has one downside: the canvas is blank until the fetch resolves. For apps that care about FOIT/FOUT, ship a tiny system-ish fallback font bundled and fetch the real font asynchronously. The fallback renders the first frame; the real font replaces it the moment it lands:

#![allow(unused)]
fn main() {
use blinc_app::web::WebApp;
use blinc_app::BlincError;
use blinc_platform_web::WebAssetLoader;

// Tiny system-ish fallback bundled inside the wasm — ~50-100 KB.
const FALLBACK_TTF: &[u8] = include_bytes!("../fonts/SystemFallback.ttf");

WebApp::run_with_async_setup(
    "blinc-canvas",
    |app| Box::pin(async move {
        // 1. Bundled fallback first — first frame renders text immediately.
        app.load_font_data(FALLBACK_TTF.to_vec());

        // 2. Fetch the real font in parallel — replaces the fallback
        //    once it lands. The font registry handles the override
        //    automatically by face name + weight.
        let inter = WebAssetLoader::fetch_bytes("fonts/Inter.ttf")
            .await
            .map_err(|e| BlincError::Platform(e.to_string()))?;
        app.load_font_data(inter);
        Ok(())
    }),
    build_ui,
)
.await
}

This is the production-grade pattern. The bundled fallback keeps the wasm artifact moderate-sized (a real 750 KB font is replaced by a 50-100 KB stripped subset), the first frame renders immediately, and the high-quality font replaces the fallback transparently.

Multiple fonts

load_font_data is additive — call it once per font:

#![allow(unused)]
fn main() {
WebApp::run_with_async_setup(
    "blinc-canvas",
    |app| Box::pin(async move {
        let inter = WebAssetLoader::fetch_bytes("fonts/Inter-Regular.ttf").await?;
        app.load_font_data(inter);

        let inter_bold = WebAssetLoader::fetch_bytes("fonts/Inter-Bold.ttf").await?;
        app.load_font_data(inter_bold);

        let mono = WebAssetLoader::fetch_bytes("fonts/JetBrainsMono.ttf").await?;
        app.load_font_data(mono);
        Ok(())
    }),
    build_ui,
).await
}

For lots of fonts, parallelize via futures::join! or wasm_bindgen_futures::spawn_local so the network round-trips overlap:

#![allow(unused)]
fn main() {
let (inter_regular, inter_bold, mono) = futures::join!(
    WebAssetLoader::fetch_bytes("fonts/Inter-Regular.ttf"),
    WebAssetLoader::fetch_bytes("fonts/Inter-Bold.ttf"),
    WebAssetLoader::fetch_bytes("fonts/JetBrainsMono.ttf"),
);

app.load_font_data(inter_regular?);
app.load_font_data(inter_bold?);
app.load_font_data(mono?);
}

Other assets

WebAssetLoader::preload(urls) is the API for general-purpose asset preloading. Unlike fetch_bytes, it stores fetched bytes in the loader’s HashMap so later synchronous AssetLoader::load(...) calls can resolve them:

#![allow(unused)]
fn main() {
use blinc_platform_web::WebAssetLoader;
use blinc_platform::assets::AssetPath;

let loader = WebAssetLoader::new();

// Fetch + cache
loader.preload(&[
    "images/logo.png",
    "icons/menu.svg",
    "data/translations.json",
]).await?;

// Synchronous lookup later (e.g. from a render handler)
let logo_bytes = loader.load(&AssetPath::Relative("images/logo.png".into()))?;
}

This is the pattern Blinc’s image loader, SVG loader, and any custom asset consumer expects: bytes are pre-loaded into a cache up front via preload, and downstream consumers do synchronous load(...) calls that resolve from the cache. The synchronous AssetLoader::load call panics if the asset isn’t in the cache — the trait is sync because the rest of Blinc is sync, and the browser doesn’t let you block on I/O from the main thread, so the only way to satisfy the contract is to pre-fetch everything you’ll need.

For one-shot bytes that have a downstream owner (like fonts), use fetch_bytes. For bytes that need synchronous lookup later (images, SVG, JSON config), use preload + load.

Why no system fonts?

Browser-provided fonts (system fonts, @font-face declarations, the FontFace API) are NOT accessible from wgpu. They live in the browser’s compositor and 2D-canvas pipeline, not in the WebGPU pipeline. Blinc rasterizes glyphs in wasm via swash, which operates on TTF/OTF bytes — and those bytes have to come from somewhere the wasm runtime can read, which on the browser means either the wasm artifact itself or a fetch() response.

The Local Font Access API (Working Draft) would allow Blinc to enumerate system fonts and request their bytes, but it’s only shipped in Chrome (gated behind a permission prompt) and isn’t widely supported. Until that changes, fetched-or-bundled is the only path.

Elements & Layout

Blinc provides a set of core elements that can be composed to build any UI. All elements implement the ElementBuilder trait and use a fluent builder pattern.

Core Elements

Div - The Universal Container

div() is the primary building block. It’s a flexible container that supports:

  • Flexbox layout
  • Background colors and materials
  • Borders and shadows
  • Event handling
  • Child elements
#![allow(unused)]
fn main() {
div()
    .w(200.0)
    .h(100.0)
    .bg(Color::rgba(0.2, 0.2, 0.3, 1.0))
    .rounded(8.0)
    .flex_center()
    .child(text("Hello"))
}

Text - Typography

text(content) renders text with customizable typography:

#![allow(unused)]
fn main() {
text("Hello, World!")
    .size(24.0)
    .weight(FontWeight::Bold)
    .color(Color::WHITE)
    .family("Inter")
}

Text Properties:

  • .size(px) - Font size in pixels
  • .weight(FontWeight) - Bold, SemiBold, Medium, Regular, Light
  • .color(Color) - Text color
  • .family(name) - Font family
  • .italic() - Italic style
  • .underline() - Underline decoration
  • .line_height(multiplier) - Line height as multiplier of font size
  • .letter_spacing(px) - Space between characters
  • .align(TextAlign) - Left, Center, Right, Justify

Typography Helpers:

#![allow(unused)]
fn main() {
h1("Heading 1")      // 32px bold
h2("Heading 2")      // 28px bold
h3("Heading 3")      // 24px bold
h4("Heading 4")      // 20px semibold
h5("Heading 5")      // 16px semibold
h6("Heading 6")      // 14px semibold
p("Paragraph")       // 14px regular
caption("Caption")   // 12px regular
label("Label")       // 14px medium
muted("Muted text")  // Reduced opacity
b("Bold text")       // Bold weight
small("Small")       // 12px
}

Stack - Overlapping Layers

stack() positions children on top of each other, useful for overlays and layered designs:

#![allow(unused)]
fn main() {
stack()
    .w(200.0)
    .h(200.0)
    // Background layer
    .child(
        div().w_full().h_full().bg(Color::BLUE)
    )
    // Foreground layer
    .child(
        div()
            .absolute()
            .right(10.0)
            .bottom(10.0)
            .w(50.0)
            .h(50.0)
            .bg(Color::RED)
    )
}

Canvas - Custom Drawing

canvas(render_fn) provides direct GPU drawing access:

#![allow(unused)]
fn main() {
canvas(|ctx: &mut dyn DrawContext, bounds| {
    ctx.fill_rect(
        Rect::new(0.0, 0.0, bounds.width, bounds.height),
        CornerRadius::uniform(8.0),
        Brush::Solid(Color::RED),
    );
})
.w(200.0)
.h(100.0)
}

See Canvas Drawing for more details.

Image & SVG

#![allow(unused)]
fn main() {
// Raster images
image("path/to/image.png")
    .w(200.0)
    .h(150.0)
    .cover()  // Object-fit: cover

// SVG with tint
svg("path/to/icon.svg")
    .w(24.0)
    .h(24.0)
    .tint(Color::WHITE)
}

See Images & SVG for more details.


Layout System

Blinc uses Flexbox for layout, powered by Taffy.

Sizing

#![allow(unused)]
fn main() {
div()
    .w(200.0)           // Fixed width in pixels
    .h(100.0)           // Fixed height in pixels
    .w_full()           // 100% width
    .h_full()           // 100% height
    .w_auto()           // Auto width (content-based)
    .h_auto()           // Auto height (content-based)
    .w_fit()            // Shrink-wrap to content
    .size(200.0, 100.0) // Set both dimensions
    .square(100.0)      // Square element
    .min_w(50.0)        // Minimum width
    .max_w(500.0)       // Maximum width
    .min_h(50.0)        // Minimum height
    .max_h(300.0)       // Maximum height
    .aspect_ratio(16.0 / 9.0)  // Maintain aspect ratio
}

Flex Container

#![allow(unused)]
fn main() {
div()
    .flex()             // Enable flexbox
    .flex_row()         // Horizontal layout (default)
    .flex_col()         // Vertical layout
    .flex_row_reverse() // Right to left
    .flex_col_reverse() // Bottom to top
    .flex_wrap()        // Wrap children
}

Flex Items

#![allow(unused)]
fn main() {
div()
    .flex_grow()        // Grow to fill space (flex-grow: 1)
    .flex_shrink()      // Allow shrinking (flex-shrink: 1)
    .flex_shrink_0()    // Don't shrink (flex-shrink: 0)
    .flex_1()           // flex: 1 1 0% (grow and shrink)
    .flex_auto()        // flex: 1 1 auto
}

Alignment

Align Items (cross-axis alignment):

#![allow(unused)]
fn main() {
div()
    .items_start()      // Align to start
    .items_center()     // Center alignment
    .items_end()        // Align to end
    .items_stretch()    // Stretch to fill
    .items_baseline()   // Align baselines
}

Justify Content (main-axis distribution):

#![allow(unused)]
fn main() {
div()
    .justify_start()    // Pack at start
    .justify_center()   // Center items
    .justify_end()      // Pack at end
    .justify_between()  // Space between items
    .justify_around()   // Space around items
    .justify_evenly()   // Equal spacing
}

Convenience Methods:

#![allow(unused)]
fn main() {
div().flex_center()     // Center both axes
div().flex_col().justify_center().items_center()  // Same as above
}

Gap (Spacing Between Children)

#![allow(unused)]
fn main() {
div()
    .gap(16.0)          // Gap in pixels
    .gap_x(8.0)         // Horizontal gap only
    .gap_y(12.0)        // Vertical gap only
}

Padding

Padding uses a 4px unit system (like Tailwind CSS):

#![allow(unused)]
fn main() {
div()
    .p(4.0)             // 16px padding all sides (4 * 4px)
    .px(2.0)            // 8px horizontal padding
    .py(3.0)            // 12px vertical padding
    .pt(1.0)            // 4px top padding
    .pr(2.0)            // 8px right padding
    .pb(3.0)            // 12px bottom padding
    .pl(4.0)            // 16px left padding
    .p_px(20.0)         // 20px (exact pixels, not units)
}

Margin

Same unit system as padding:

#![allow(unused)]
fn main() {
div()
    .m(4.0)             // 16px margin all sides
    .mx(2.0)            // 8px horizontal margin
    .my(3.0)            // 12px vertical margin
    .mt(1.0)            // 4px top margin
    .mr(2.0)            // 8px right margin
    .mb(3.0)            // 12px bottom margin
    .ml(4.0)            // 16px left margin
    .mx_auto()          // Auto horizontal margins (centering)
}

Positioning

#![allow(unused)]
fn main() {
div()
    .relative()         // Position relative
    .absolute()         // Position absolute
    .inset(10.0)        // 10px from all edges
    .top(20.0)          // 20px from top
    .right(20.0)        // 20px from right
    .bottom(20.0)       // 20px from bottom
    .left(20.0)         // 20px from left
}

Overflow

#![allow(unused)]
fn main() {
div()
    .overflow_clip()    // Clip overflowing content
    .overflow_visible() // Allow overflow
    .overflow_scroll()  // Enable scrolling
}

Common Layout Patterns

Centered Content

#![allow(unused)]
fn main() {
div()
    .w_full()
    .h_full()
    .flex_center()
    .child(content)
}
#![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

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:

ValueShapeDescription
1RoundStandard circular arc (default)
0BevelStraight diagonal cut
2SquircleSmoother than circular (iOS-style)
-1ScoopConcave inward curve
100SquareSharp corner (ignores border-radius)
-100NotchSharp 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

FunctionDescription
easeSlow start and end (default)
linearConstant speed
ease-inSlow start
ease-outSlow end
ease-in-outSlow 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-radius
  • box-shadow, text-shadow, outline, color, font-size
  • width, height, padding, margin, gap, min-width, max-width
  • filter (blur, brightness, contrast, etc.)
  • backdrop-filter
  • clip-path
  • fill, stroke, stroke-width, stroke-dasharray, stroke-dashoffset
  • d (SVG path morphing)
  • rotate-x, rotate-y, perspective, translate-z
  • light-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:

TokenDescription
primary, primary-hover, primary-activePrimary brand colors
secondary, secondary-hover, secondary-activeSecondary colors
success, success-bgSuccess states
warning, warning-bgWarning states
error, error-bgError states
info, info-bgInfo states
background, surface, surface-elevated, surface-overlayBackground surfaces
text-primary, text-secondary, text-tertiary, text-inverse, text-linkText colors
border, border-secondary, border-hover, border-focus, border-errorBorders

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

UnitDescriptionExample
pxPixels (default)12px
%Percentage of parent50%
spSpacing units (1sp = 4px)4sp = 16px
degDegrees (angles)45deg
turnFull turns (angles)0.25turn = 90deg
radRadians (angles)1.5708rad ≈ 90deg
msMilliseconds (time)300ms
sSeconds (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

FunctionSyntaxDescription
minmin(a, b)Smaller of two values
maxmax(a, b)Larger of two values
clampclamp(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!
NamingCSS hyphens (border-radius)Rust underscores (rounded)
SeparatorSemicolonsCommas
Enum valuesLiteral keywords (position: absolute;)Rust expressions (position: StylePosition::Absolute)
Best forDevelopers from CSS/webRust-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

ApproachBest For
Global CSS (ctx.add_css())Shared styles, hover/focus states, animations, selectors
css! / style! macrosScoped 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:

  1. Global stylesheetctx.add_css() + CSS selectors (recommended for most styling)
  2. Scoped macroscss! / style! macros for inline ElementStyle
  3. 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:

  1. Tree build — Elements are created, RenderProps initialized
  2. Base styles — Non-state CSS rules applied (complex selectors first, then ID selectors for higher specificity)
  3. Layout overrides — CSS layout properties (width, padding, gap, etc.) modify the flexbox tree
  4. Layout computation — Flexbox layout calculated via Taffy
  5. State styles — Hover/focus/checked states matched, transitions detected
  6. Animation tick — CSS @keyframes animations advance
  7. Transition tick — CSS transitions interpolate toward target
  8. Layout animation — If animated properties affect layout, re-compute flexbox
  9. Render — Final RenderProps sent to GPU

Specificity

Rules follow CSS specificity, applied in order:

  1. Type/class/combinator selectors (lowest)
  2. ID selectors (highest)
  3. Later rules override earlier rules at the same specificity level
  4. State styles (:hover, etc.) layer on top of base styles

Property Comparison

The same property expressed across all three approaches:

Global CSScss! macrostyle! macroBuilder 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_ready callbacks fire multiple times: During theme animation, on_ready may 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

CategoryTokensDescription
BrandPrimary, PrimaryHover, PrimaryActive, Secondary, SecondaryHover, SecondaryActiveMain brand colors
SemanticSuccess, Warning, Error, Info + *Bg variantsStatus/feedback colors
SurfaceBackground, Surface, SurfaceElevated, SurfaceOverlayBackground layers
TextTextPrimary, TextSecondary, TextTertiary, TextInverse, TextLinkText colors
BorderBorder, BorderHover, BorderFocus, BorderErrorBorder states
InputInputBg, InputBgHover, InputBgFocus, InputBgDisabledForm input backgrounds
SelectionSelection, SelectionTextText selection colors
AccentAccent, AccentSubtleAccent 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:

TokenSizeUse Case
text_xs12pxCaptions, labels
text_sm14pxSecondary text, buttons
text_base16pxBody text
text_lg18pxLarge body text
text_xl20pxSmall headings
text_2xl24pxSection headings
text_3xl30pxPage headings
text_4xl36pxLarge headings
text_5xl48pxHero 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:

TokenValueUse Case
space_14pxMinimal spacing
space_28pxTight spacing
space_2_510pxBetween tight and standard
space_312pxStandard small
space_416pxStandard spacing
space_520pxMedium spacing
space_624pxLarge spacing
space_832pxSection spacing
space_1040pxLarge section spacing
space_1248pxExtra 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:

TokenValueUse Case
radius_none0pxSharp corners
radius_sm4pxSubtle rounding
radius_md6pxStandard rounding
radius_lg8pxPronounced rounding
radius_xl12pxLarge rounding
radius_2xl16pxExtra large rounding
radius_full9999pxPill 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

  1. Theme colors are stored as AnimatedValue in the global ThemeState
  2. When the scheme changes, target colors animate from current to new values
  3. The animation scheduler drives smooth interpolation
  4. 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

RoleColor
Background#EFF1F5
Surface#FFFFFF
Text Primary#4C4F69
Primary#1E66F5 (Blue)
Success#40A02B (Green)
Warning#DF8E1D (Yellow)
Error#D20F39 (Red)

Mocha (Dark) Palette

RoleColor
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:

PlatformDetection Method
macOSAppleInterfaceStyle from UserDefaults
WindowsWindows.UI.ViewManagement API
LinuxXDG/GTK settings
iOSNative UITraitCollection
AndroidConfiguration.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

  1. The watcher runs in a background thread named blinc-scheme-watcher
  2. It polls the system color scheme at the configured interval
  3. When a change is detected, it calls ThemeState::set_scheme() automatically
  4. Theme transitions are animated smoothly using spring physics
  5. 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

MethodTriggers
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

  1. Always use semantic tokens - Use ColorToken::Primary instead of hardcoded colors for automatic theme support.

  2. Read colors at render time - Access ThemeState::get() inside your component function, not at module level.

  3. Fetch in callbacks - For on_state and other callbacks, fetch theme colors inside the callback to respond to theme changes.

  4. Use spacing scale - Use theme.spacing().space_* for consistent visual rhythm.

  5. Match radius to context - Use smaller radii for small elements, larger for cards and panels.

  6. 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 RwLock for 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

  1. Keep handlers lightweight - Do minimal work in event handlers. For heavy operations, queue work or update state.

  2. Use stateful::<S>() for hover/press - Instead of manually tracking hover state, use stateful::<ButtonState>() which handles state transitions automatically.

  3. Clone before closures - Clone Arc, signals, or context references before moving them into closures.

  4. Avoid nested event handlers - Events bubble up, so you rarely need deeply nested handlers.

  5. Use local coordinates - For hit testing within an element, use ctx.local_x and ctx.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

  1. stateful::<S>() creates a StatefulBuilder for state type S
  2. .on_state(|ctx| ...) defines the callback that receives a StateContext
  3. Events (hover, click, etc.) trigger automatic state transitions
  4. ctx.state() returns the current state for pattern matching
  5. Return a Div from 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

MethodDescription
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 handle
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

FieldTypeDescription
event_typeu32Event type (POINTER_UP, POINTER_ENTER, etc.)
node_idLayoutNodeIdThe node that received the event
mouse_x, mouse_yf32Absolute mouse position
local_x, local_yf32Position relative to element bounds
bounds_x, bounds_yf32Element position (top-left corner)
bounds_width, bounds_heightf32Element dimensions
scroll_delta_x, scroll_delta_yf32Scroll delta (for SCROLL events)
drag_delta_x, drag_delta_yf32Drag offset (for DRAG events)
key_charOption<char>Character (for TEXT_INPUT events)
key_codeu32Key code (for KEY_DOWN/KEY_UP events)
shift, ctrl, alt, metaboolModifier 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

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:

  • IdleHovered (on pointer enter)
  • HoveredIdle (on pointer leave)
  • HoveredPressed (on pointer down)
  • PressedHovered (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

  1. Use stateful::<S>() builder - This is the primary pattern for stateful UI elements.

  2. Return Div from callbacks - The new API expects you to return a Div, not mutate a container.

  3. Use .initial() for non-default states - Set initial state explicitly when needed.

  4. Use ctx.use_signal() for local state - Scoped signals are automatically keyed.

  5. Use ctx.dep() for dependency access - Cleaner than capturing signals in closures.

  6. Prefer built-in state types - They have correct transitions already defined.

  7. Custom states for complex flows - Define your own when built-in types don’t fit.

  8. Use .deps() for external dependencies - When on_state needs 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 CasePresetFeel
Button press feedbackstiff()Immediate, snappy
Menu/panel transitionssnappy()Quick with character
Drag releasegentle()Smooth, natural
Playful interactionswobbly()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

  1. Match spring to interaction - Use stiffer springs for immediate feedback, gentler for ambient motion.

  2. Persist animated values - Use ctx.use_animated_value() so animations survive UI rebuilds.

  3. Clone Arc before closures - Always Arc::clone() before moving into event handlers.

  4. Don’t fight the spring - Let animations complete naturally. Interrupting with new targets is fine.

  5. 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

FeatureTimelineSpring
DurationFixedPhysics-based
LoopingBuilt-inManual
Multiple valuesSingle timelineIndividual values
Ping-pongset_alternate(true)Manual reverse
InterruptionRestart neededNatural blend
Use caseContinuous loops, sequencesInteractive, 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

FeatureMotionAnimatedValue
SetupDeclarativeImperative
ControlPreset-basedFull control
Enter/ExitBuilt-inManual
ListsStagger supportManual delays
Use caseTransitionsInteractive

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:

  1. animate_bounds() (Rust API) — Spring-physics-driven, animates position, size, or both. Used by components like accordion and sidebar.
  2. 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:

  1. First — Snapshot every element’s bounds before the layout change.
  2. Last — Compute the new layout after the change.
  3. Invert — Apply an offset that moves each element back to where it was.
  4. 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

PresetAnimatesUse Case
height()HeightAccordion panels, collapsible content
width()WidthSidebar expand/collapse
size()Width + HeightContainers that resize both axes
position()X + YItems that shift when siblings change
all()Position + SizeFull 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:

  1. Before rebuild: Snapshot each element’s bounds (keyed by stable key).
  2. After rebuild: Taffy computes new layout (final positions).
  3. Detect changes: Compare old bounds to new bounds per key.
  4. Create spring animations: For each changed element, create AnimatedValue springs that start at the delta (old - new) and target 0.
  5. 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:

  1. The items container width shrinks (text labels disappear, only icons remain).
  2. clip_to_animated() hides overflowing text during the width transition.
  3. 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:

  1. An element has a stable string ID (.id("my-item")).
  2. A CSS transition on transform is 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

Featureanimate_bounds()CSS FLIP
Animation engineSpring physicsCSS timing functions (ease, linear)
Animates positionYesYes
Animates sizeYesNo
Clip during animationYes (clip_to_animated)No
ConfigurationRust builder APICSS transition property
Best forAccordions, sidebars, panelsSortable 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:

  1. State Management - Generate State<T> hooks for component data (counters, toggles, form values)
  2. Animations - Generate SharedAnimatedValue hooks 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 key
  • MyComponent::use_animated_value(ctx, initial, config) - Spring animation
  • MyComponent::use_animated_value_with(ctx, suffix, initial, config) - Named spring
  • MyComponent::use_animated_timeline(ctx) - Keyframe timeline
  • MyComponent::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

  1. Group related state and animations - A component should represent one logical UI element with its related state and animations.

  2. Use fields for named values - Prefer #[animation] scale: f32 over use_animated_value_with(ctx, "scale", ...).

  3. Combine state and animations - Use state fields for data, animation fields for visual transitions.

  4. 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,
}
}
  1. Use motion() with animated values - Wrap content using animated values in motion() 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

  1. Keep components focused - One component, one responsibility.

  2. Use impl ElementBuilder - For maximum flexibility in return types.

  3. Document public components - Add doc comments explaining usage.

  4. Consistent naming - Use descriptive names that indicate the component’s purpose.

  5. Default sensible styles - Provide good defaults, allow overrides.

  6. Separate stateless and stateful - Pure components are easier to test and reuse.

  7. Use BlincComponent for state and animations - Type-safe hooks for both State<T> and SharedAnimatedValue prevent key collisions.

  8. Use stateful::<S>() for visual states - Hover, press, focus effects should use stateful containers rather than signals.

  9. 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

CategoryComponents
ButtonsButton
CardsCard, CardHeader, CardContent, CardFooter
DialogsDialog, AlertDialog, Sheet, Drawer
FormsInput, Textarea, Checkbox, Switch, Radio, Select, Slider
NavigationTabs, DropdownMenu, ContextMenu, Breadcrumb, Sidebar
FeedbackAlert, Badge, Progress, Spinner, Skeleton, Toast
LayoutAvatar, Separator, AspectRatio, ScrollArea, Accordion
DataTooltip, 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

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

PropTypeDefaultDescription
variantButtonVariantPrimaryVisual style
sizeButtonSizeDefaultButton size
disabledboolfalseDisable interaction
loadingboolfalseShow loading state
full_widthboolfalseExpand to full width
icon&strNoneIcon before text
icon_right&strNoneIcon after text

Events

EventTypeDescription
on_clickFn()Called when clicked
on_hoverFn(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 */)
}

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()

PropTypeDescription
Standard div props-All div styling props

card_header()

PropTypeDescription
Standard div props-All div styling props

card_title()

PropTypeDescription
Text content&strTitle text

card_description()

PropTypeDescription
Text content&strDescription 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"))
}

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()

PropTypeDefaultDescription
openboolfalseWhether dialog is open
on_open_changeFn(bool)-Called when open state changes

sheet()

PropTypeDefaultDescription
openboolfalseWhether sheet is open
sideSheetSideRightWhich side to slide from
on_open_changeFn(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...")))
}
#![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"))
}
#![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"))))
}

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")))))
}

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()))
}

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 */)
}

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, typescript
  • html, css, json, yaml, xml
  • sql, 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:

WidgetState TypeStates
ButtonButtonStateIdle, Hovered, Pressed, Disabled
CheckboxCheckboxStateUncheckedIdle, UncheckedHovered, CheckedIdle, CheckedHovered
TextInputTextFieldStateIdle, Hovered, Focused, FocusedHovered, Disabled
TextAreaTextFieldStateSame as TextInput

Best Practices

  1. Use unique keys for state - Each widget needs its own state key.

  2. Handle validation in on_change - Validate input as users type.

  3. Provide visual feedback - Use colors to indicate focus and errors.

  4. Group related inputs - Use flex containers to organize forms.

  5. 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

MethodDescription
.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

TagEffect
<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 &lt;b&gt; for bold")  // Renders: Use <b> for bold
rich_text("&copy; 2024 &bull; All Rights Reserved &trade;")
rich_text("&ldquo;Smart quotes&rdquo; &mdash; and &hellip;")
}

Supported entities: &lt;, &gt;, &amp;, &quot;, &apos;, &nbsp;, &copy;, &reg;, &trade;, &mdash;, &ndash;, &hellip;, &lsquo;, &rsquo;, &ldquo;, &rdquo;, &bull;, &middot;, and numeric entities (&#65;, &#x41;)

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)

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)
}

For simple clickable text, use the link() widget:

#![allow(unused)]
fn main() {
// Default behavior - opens URL in browser
link("Click here", "https://example.com")

// Custom styling
link("Styled link", "https://example.com")
    .size(18.0)
    .color(Color::CYAN)
    .no_underline()

// Underline only on hover
link("Hover to see underline", "https://example.com")
    .underline_on_hover()
}

From StyledText

For integration with syntax highlighting or markdown rendering, create rich text from a pre-built StyledText:

#![allow(unused)]
fn main() {
use blinc_layout::styled_text::{StyledText, StyledLine, TextSpan};

let styled = StyledText {
    lines: vec![
        StyledLine {
            text: "Hello World".to_string(),
            spans: vec![
                TextSpan {
                    start: 0,
                    end: 5,
                    bold: true,
                    color: Color::RED,
                    ..Default::default()
                },
            ],
        },
    ],
};

rich_text_styled(styled)
    .size(16.0)
    .default_color(Color::WHITE)
}

Example

Here’s a complete example demonstrating various text features:

#![allow(unused)]
fn main() {
use blinc_app::prelude::*;
use blinc_core::Color;

fn demo_ui() -> impl ElementBuilder {
    div()
        .flex_col()
        .gap(16.0)
        .p(20.0)
        // Plain text
        .child(
            text("Plain Text Example")
                .size(24.0)
                .color(Color::WHITE)
                .bold()
        )
        // Rich text with inline formatting
        .child(
            rich_text("This is <b>bold</b>, <i>italic</i>, and <span color=\"#00FF00\">green</span>.")
                .size(16.0)
                .default_color(Color::WHITE)
        )
        // Interactive link
        .child(
            rich_text(r#"Visit <a href="https://github.com">GitHub</a> for more."#)
                .size(16.0)
                .default_color(Color::WHITE)
        )
        // Range-based styling
        .child(
            rich_text("Programmatic styling with ranges")
                .bold_range(0..13)
                .color_range(14..21, Color::CYAN)
                .underline_range(22..32)
                .size(16.0)
                .default_color(Color::WHITE)
        )
}
}

Run the rich text demo to see all features in action:

cargo run -p blinc_app_examples --example rich_text_demo --features windowed

Code Editor

The code_editor widget provides a full-featured code editing experience with syntax highlighting, line numbers, folding, search, and more.

Read-Only Code Block

Display syntax-highlighted code:

use blinc_layout::prelude::*;
use blinc_layout::syntax::{SyntaxConfig, RustHighlighter};

code(r#"fn main() { println!("Hello"); }"#)
    .syntax(SyntaxConfig::new(RustHighlighter::new()))
    .line_numbers(true)
    .font_size(14.0)
    .w_full()

Editable Code Editor

Full editor with Stateful incremental updates:

#![allow(unused)]
fn main() {
let state = code_editor_state("let x = 42;");

code_editor(&state)
    .syntax(SyntaxConfig::new(RustHighlighter::new()))
    .line_numbers(true)
    .font_size(13.0)
    .on_change(|new_content| {
        println!("Content: {}", new_content);
    })
    .w_full()
    .h(400.0)
}

Features

Editing

  • Type, Enter (auto-indent), Backspace, Delete
  • Tab / Shift+Tab: indent/dedent selected lines
  • Cmd+Backspace/Delete: delete word backward/forward
  • Arrow keys (with Shift for selection)
  • Cmd+Left/Right: word jump
  • Smart Home: toggle between first non-whitespace and column 0
  • Page Up/Down
  • Mouse click cursor positioning

Clipboard & Undo

  • Cmd+C/X/V: copy/cut/paste
  • Cmd+Z / Cmd+Shift+Z: undo/redo (200-entry history)
  • Cmd+A: select all

Visual Features

  • Syntax highlighting (Rust, JSON, or custom highlighters)
  • Line numbers with gutter
  • Current line highlight
  • Selection rendering
  • Bracket matching
  • Indentation guides
  • Code folding (click gutter chevrons)
  • Minimap (optional scaled-down overview)

Search (Cmd+F)

  • VS Code-style search bar overlay
  • Case sensitive, whole word, regex toggles
  • Match highlighting with navigation (up/down arrows)
  • Find and replace with replace all

Syntax Highlighters

Built-in highlighters:

#![allow(unused)]
fn main() {
use blinc_layout::syntax::*;

// Rust
SyntaxConfig::new(RustHighlighter::new())

// JSON
SyntaxConfig::new(JsonHighlighter::new())

// Plain text with custom colors
SyntaxConfig::new(
    PlainHighlighter::new()
        .text_color(Color::rgba(0.8, 0.9, 0.8, 1.0))
        .background(Color::rgba(0.1, 0.12, 0.1, 1.0))
)
}

Custom Highlighter

Implement the SyntaxHighlighter trait:

#![allow(unused)]
fn main() {
struct MyHighlighter;

impl SyntaxHighlighter for MyHighlighter {
    fn token_rules(&self) -> &[TokenRule] {
        &[
            TokenRule::new(r"//.*$", Color::GREEN, false, TokenType::Comment),
            TokenRule::new(r#""[^"]*""#, Color::ORANGE, false, TokenType::String),
            TokenRule::new(r"\b(fn|let|if|else)\b", Color::PURPLE, true, TokenType::Keyword),
        ]
    }

    fn default_color(&self) -> Color { Color::WHITE }
    fn background_color(&self) -> Color { Color::rgb(0.1, 0.1, 0.12) }
}
}

Configuration

#![allow(unused)]
fn main() {
code_editor(&state)
    .line_numbers(true)        // Show line numbers
    .font_size(13.0)           // Font size in pixels
    .line_height(1.5)          // Line height multiplier
    .padding(16.0)             // Content padding
    .code_bg(Color::BLACK)     // Background color
    .text_color(Color::WHITE)  // Default text color
    .edit(true)                // Enable editing (default for code_editor)
    .indent_guides(true)       // Show vertical indent guides
    .code_folding(true)        // Enable fold/unfold
    .minimap(true)             // Show minimap sidebar
}

Scroll Containers

Blinc provides scroll containers with WebKit-style momentum scrolling and bounce physics.

Basic Scroll

#![allow(unused)]
fn main() {
use blinc_layout::widgets::scroll::scroll;

fn scrollable_content() -> impl ElementBuilder {
    scroll()
        .h(400.0)
        .child(
            div()
                .flex_col()
                .gap(8.0)
                .child(/* ... long content ... */)
        )
}
}

Scroll Without Bounce

#![allow(unused)]
fn main() {
use blinc_layout::widgets::scroll::scroll_no_bounce;

scroll_no_bounce()
    .h(400.0)
    .child(content)
}

Scroll Configuration

#![allow(unused)]
fn main() {
use blinc_layout::widgets::scroll::{Scroll, ScrollConfig, ScrollDirection};
use blinc_animation::SpringConfig;

Scroll::with_config(ScrollConfig {
    bounce_enabled: true,
    bounce_spring: SpringConfig::wobbly(),
    deceleration: 1500.0,
    velocity_threshold: 10.0,
    max_overscroll: 0.3,  // 30% of viewport
    direction: ScrollDirection::Vertical,
})
.h(400.0)
.child(content)
}

Configuration Presets

#![allow(unused)]
fn main() {
ScrollConfig::default()       // Standard bounce
ScrollConfig::no_bounce()     // No bounce physics
ScrollConfig::stiff_bounce()  // Tight, minimal bounce
ScrollConfig::gentle_bounce() // Soft, more bounce
}

Scroll Directions

#![allow(unused)]
fn main() {
// Vertical only (default)
Scroll::with_config(ScrollConfig {
    direction: ScrollDirection::Vertical,
    ..Default::default()
})

// Horizontal only
Scroll::with_config(ScrollConfig {
    direction: ScrollDirection::Horizontal,
    ..Default::default()
})

// Both directions
Scroll::with_config(ScrollConfig {
    direction: ScrollDirection::Both,
    ..Default::default()
})
}

Scroll States

Scroll containers use ScrollState for physics-driven behavior:

#![allow(unused)]
fn main() {
ScrollState::Idle         // Not scrolling
ScrollState::Scrolling    // User is dragging
ScrollState::Decelerating // Momentum after release
ScrollState::Bouncing     // Edge bounce animation
}

Example: Scrollable List

#![allow(unused)]
fn main() {
fn message_list() -> impl ElementBuilder {
    scroll()
        .h(500.0)
        .w_full()
        .child(
            div()
                .flex_col()
                .gap(8.0)
                .p(16.0)
                .child(
                    (0..50).map(|i| {
                        div()
                            .p(12.0)
                            .rounded(8.0)
                            .bg(Color::rgba(0.15, 0.15, 0.2, 1.0))
                            .child(
                                text(&format!("Message {}", i + 1))
                                    .color(Color::WHITE)
                            )
                    })
                )
        )
}
}
#![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

ParameterDefaultDescription
deceleration1500.0How quickly momentum decays (higher = faster stop)
velocity_threshold10.0Minimum velocity to continue momentum
max_overscroll0.3Maximum overscroll as fraction of viewport
bounce_springwobblySpring 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 ValueDescription
StartAlign to top/left of viewport
CenterAlign to center of viewport
EndAlign to bottom/right of viewport
NearestScroll 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();
}

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, &current_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

  1. Set explicit height - Scroll containers need a bounded height to work.

  2. Use overflow_clip on parent - Ensure parent clips overflowing content.

  3. Prefer vertical for long content - Horizontal scrolling is less intuitive for lists.

  4. Consider no-bounce for forms - Disable bounce for content that needs precise positioning.

  5. Test nested scrolling - Verify inner/outer scroll interactions work as expected.

  6. Use meaningful element IDs - Choose descriptive IDs like "message-123" or "section-intro" for elements you need to scroll to.

  7. Prefer ctx.use_scroll_ref() - Always use the context method rather than ScrollRef::new() for proper reactive integration.

Virtualized List

The virtual_list widget efficiently renders large datasets by only creating elements for a window of visible items. Items can have variable heights — flexbox layout determines their size.

Basic Usage

#![allow(unused)]
fn main() {
use blinc_layout::widgets::virtual_list::virtual_list;

let items: Vec<String> = (0..10_000)
    .map(|i| format!("Item {}", i))
    .collect();

virtual_list(items.len(), move |index| {
    div()
        .w_full()
        .p_px(8.0)
        .flex_row()
        .items_center()
        .child(text(&items[index]).size(14.0).color(Color::WHITE))
})
.w_full()
.h(400.0)
.into_div()
}

Variable Height Items

Items don’t need a fixed height. Flexbox handles sizing:

#![allow(unused)]
fn main() {
virtual_list(messages.len(), move |i| {
    let msg = &messages[i];
    div()
        .w_full()
        .p_px(12.0)
        .flex_col()
        .gap_px(4.0)
        .child(text(&msg.author).size(12.0).bold().color(Color::WHITE))
        .child(text(&msg.body).size(14.0).color(Color::rgba(0.8, 0.8, 0.8, 1.0)))
        // Height is determined by content — short messages are small, long ones wrap
})
.w_full()
.h(600.0)
.into_div()
}

Configuration

#![allow(unused)]
fn main() {
virtual_list(count, builder)
    .w_full()                       // Width
    .h(400.0)                       // Viewport height
    .bg(Color::BLACK)               // Background
    .rounded(8.0)                   // Corner radius
    .gap_px(4.0)                    // Gap between items
    .estimated_item_height(48.0)    // Hint for scroll spacer (default: 40px)
    .window_size(80)                // Items to render at once (default: 50)
    .into_div()
}
OptionDefaultDescription
estimated_item_height40.0Average item height estimate for scroll spacer calculation
window_size50Number of items rendered at once

How It Works

  1. The builder creates elements for the first window_size items
  2. Flexbox layout determines each item’s actual height
  3. A spacer div below the rendered items estimates the remaining scroll height
  4. The scroll container provides momentum physics scrolling
  5. Items use their natural flex-determined height — no fixed constraints

Canvas Drawing

The canvas() element provides direct GPU drawing access for custom graphics, charts, and procedural content.

Basic Usage

#![allow(unused)]
fn main() {
use blinc_core::{DrawContext, Rect, Brush, Color, CornerRadius};

canvas(|ctx: &mut dyn DrawContext, bounds| {
    // bounds contains the canvas size
    ctx.fill_rect(
        Rect::new(0.0, 0.0, bounds.width, bounds.height),
        CornerRadius::uniform(8.0),
        Brush::Solid(Color::RED),
    );
})
.w(200.0)
.h(100.0)
}

Drawing Primitives

Filled Rectangles

#![allow(unused)]
fn main() {
ctx.fill_rect(
    Rect::new(x, y, width, height),
    CornerRadius::uniform(8.0),  // Corner radius
    Brush::Solid(Color::BLUE),
);

// No corner radius
ctx.fill_rect(
    Rect::new(10.0, 10.0, 100.0, 50.0),
    CornerRadius::default(),
    Brush::Solid(Color::GREEN),
);
}

Stroked Rectangles

#![allow(unused)]
fn main() {
ctx.stroke_rect(
    Rect::new(x, y, width, height),
    CornerRadius::uniform(4.0),
    2.0,  // Stroke width
    Brush::Solid(Color::WHITE),
);
}

Circles

#![allow(unused)]
fn main() {
// Filled circle
ctx.fill_circle(
    Point::new(cx, cy),  // Center
    radius,
    Brush::Solid(Color::BLUE),
);

// Stroked circle
ctx.stroke_circle(
    Point::new(cx, cy),
    radius,
    2.0,  // Stroke width
    Brush::Solid(Color::WHITE),
);
}

Text

#![allow(unused)]
fn main() {
use blinc_core::TextStyle;

ctx.draw_text(
    "Hello, Canvas!",
    Point::new(x, y),
    &TextStyle::new(16.0).with_color(Color::WHITE),
);
}

Gradients

#![allow(unused)]
fn main() {
use blinc_core::{Gradient, GradientStop, Point};

// Linear gradient
let gradient = Brush::Gradient(Gradient::linear(
    Point::new(0.0, 0.0),      // Start
    Point::new(200.0, 0.0),    // End
    Color::rgba(0.9, 0.2, 0.5, 1.0),
    Color::rgba(0.2, 0.8, 0.6, 1.0),
));

ctx.fill_rect(
    Rect::new(0.0, 0.0, 200.0, 100.0),
    CornerRadius::default(),
    gradient,
);

// Multi-stop gradient
let gradient = Brush::Gradient(Gradient::linear_with_stops(
    Point::new(0.0, 0.0),
    Point::new(200.0, 0.0),
    vec![
        GradientStop::new(0.0, Color::RED),
        GradientStop::new(0.5, Color::YELLOW),
        GradientStop::new(1.0, Color::GREEN),
    ],
));
}

Transforms

#![allow(unused)]
fn main() {
use blinc_core::Transform;

// Push transform
ctx.push_transform(Transform::translate(50.0, 50.0));

// Draw in transformed space
ctx.fill_rect(/* ... */);

// Pop transform
ctx.pop_transform();

// Rotation
ctx.push_transform(Transform::rotate(angle_radians));
// ... draw ...
ctx.pop_transform();

// Scale
ctx.push_transform(Transform::scale(2.0, 2.0));
// ... draw ...
ctx.pop_transform();
}

Clipping

#![allow(unused)]
fn main() {
// Push clip region
ctx.push_clip(Rect::new(10.0, 10.0, 100.0, 100.0));

// Only content within clip region is visible
ctx.fill_rect(/* ... */);

// Pop clip
ctx.pop_clip();
}

Example: Animated Spinner

#![allow(unused)]
fn main() {
use std::f32::consts::PI;

fn spinner(ctx: &WindowedContext) -> impl ElementBuilder {
    let timeline = ctx.use_animated_timeline();

    let entry_id = timeline.lock().unwrap().configure(|t| {
        let id = t.add(0, 1000, 0.0, 360.0);
        t.set_loop(-1);
        t.start();
        id
    });

    let render_timeline = Arc::clone(&timeline);

    canvas(move |draw_ctx, bounds| {
        let angle_deg = render_timeline.lock().unwrap().get(entry_id).unwrap_or(0.0);
        let angle_rad = angle_deg * PI / 180.0;

        let cx = bounds.width / 2.0;
        let cy = bounds.height / 2.0;
        let radius = 30.0;

        // Draw spinning segments
        for i in 0..8 {
            let segment_angle = angle_rad + (i as f32 * PI / 4.0);
            let alpha = 1.0 - (i as f32 * 0.1);

            let x = cx + segment_angle.cos() * radius;
            let y = cy + segment_angle.sin() * radius;

            draw_ctx.fill_circle(
                Point::new(x, y),
                4.0,
                Brush::Solid(Color::rgba(0.4, 0.6, 1.0, alpha)),
            );
        }
    })
    .w(80.0)
    .h(80.0)
}
}

Example: Progress Ring

#![allow(unused)]
fn main() {
fn progress_ring(progress: f32) -> impl ElementBuilder {
    canvas(move |ctx, bounds| {
        let cx = bounds.width / 2.0;
        let cy = bounds.height / 2.0;
        let radius = bounds.width.min(bounds.height) / 2.0 - 4.0;

        // Background ring
        ctx.stroke_circle(
            Point::new(cx, cy),
            radius,
            4.0,
            Brush::Solid(Color::rgba(0.2, 0.2, 0.25, 1.0)),
        );

        // Progress arc (simplified - actual arc drawing would need path API)
        // For now, draw segments
        let segments = 32;
        let filled = (segments as f32 * progress) as i32;

        for i in 0..filled {
            let angle = (i as f32 / segments as f32) * 2.0 * PI - PI / 2.0;
            let x = cx + angle.cos() * radius;
            let y = cy + angle.sin() * radius;

            ctx.fill_circle(
                Point::new(x, y),
                3.0,
                Brush::Solid(Color::rgba(0.4, 0.6, 1.0, 1.0)),
            );
        }

        // Center text
        ctx.draw_text(
            &format!("{}%", (progress * 100.0) as i32),
            Point::new(cx - 15.0, cy + 6.0),
            &TextStyle::new(16.0).with_color(Color::WHITE),
        );
    })
    .w(80.0)
    .h(80.0)
}
}

Example: Color Palette

#![allow(unused)]
fn main() {
fn color_palette() -> impl ElementBuilder {
    canvas(|ctx, bounds| {
        let cols = 8;
        let rows = 3;
        let cell_w = bounds.width / cols as f32;
        let cell_h = bounds.height / rows as f32;

        for row in 0..rows {
            for col in 0..cols {
                let hue = col as f32 / cols as f32;
                let sat = 1.0 - (row as f32 * 0.25);
                let color = hsv_to_rgb(hue, sat, 0.9);

                ctx.fill_rect(
                    Rect::new(
                        col as f32 * cell_w,
                        row as f32 * cell_h,
                        cell_w - 2.0,
                        cell_h - 2.0,
                    ),
                    CornerRadius::uniform(4.0),
                    Brush::Solid(color),
                );
            }
        }
    })
    .w(240.0)
    .h(90.0)
}
}

Best Practices

  1. Set explicit size - Canvas needs width and height to render.

  2. Use bounds parameter - Draw relative to bounds.width and bounds.height.

  3. Clone Arcs for closures - Animation values need Arc::clone() before the render closure.

  4. Push/pop transforms - Always pop what you push to avoid state leaks.

  5. Prefer elements when possible - Use div(), text() for standard UI; canvas for custom graphics.

Images & SVG

Blinc supports raster images and SVG graphics with flexible sizing and styling options.

Images

Basic Image

#![allow(unused)]
fn main() {
use blinc_layout::image::image;

image("path/to/photo.png")
    .w(200.0)
    .h(150.0)
}

Image from URL

#![allow(unused)]
fn main() {
image("https://example.com/image.jpg")
    .w(300.0)
    .h(200.0)
}

Object Fit

Control how the image fills its container:

#![allow(unused)]
fn main() {
image(src)
    .w(200.0)
    .h(200.0)
    .cover()      // Fill container, crop if needed (default)

image(src)
    .contain()    // Fit entirely, may letterbox

image(src)
    .fill()       // Stretch to fill exactly

image(src)
    .scale_down() // Scale down only if larger

image(src)
    .no_scale()   // No scaling, original size
}

Object Position

Control alignment within the container:

#![allow(unused)]
fn main() {
image(src)
    .cover()
    .center()         // Center (default)

image(src)
    .cover()
    .top_left()

image(src)
    .cover()
    .top_center()

image(src)
    .cover()
    .bottom_right()

// Custom position (0.0 to 1.0)
image(src)
    .cover()
    .position_xy(0.25, 0.75)
}

Image Filters

#![allow(unused)]
fn main() {
image(src)
    .w(200.0)
    .h(200.0)
    .grayscale(0.5)      // 0.0 = color, 1.0 = grayscale
    .sepia(0.3)          // Sepia tone
    .brightness(1.2)     // > 1.0 brighter, < 1.0 darker
    .contrast(1.1)       // > 1.0 more contrast
    .saturate(0.8)       // < 1.0 less saturated
    .hue_rotate(45.0)    // Rotate hue (degrees)
    .invert(0.2)         // Color inversion
    .blur(2.0)           // Blur radius
}

Lazy Loading

For content-heavy apps with many images (galleries, feeds, chat) lazy loading defers decode until the image is actually visible in the viewport. While the real bitmap is loading, Blinc renders a placeholder in its place; once the texture lands in cache, the image fades in over a configurable duration.

How it works

  1. On each frame, the renderer tests every lazy image’s quad against the current viewport AABB. Off-screen images are skipped — no decode, no GPU upload.
  2. When an image first intersects the viewport, the loader is triggered and a placeholder is drawn at the image’s final layout rect.
  3. As soon as the decoded texture appears in the GPU cache, Blinc records image_load_times[source] = Instant::now() and starts the fade-in. elapsed_ms / fade_duration_ms is the alpha multiplier — once it reaches 1.0 the image is fully opaque and the fade-in flag is cleared.
  4. Placeholder images (type 2) are eagerly preloaded so they’re available the moment the lazy element appears — there’s no flash of empty space waiting on the thumbnail itself.
  5. While any image is fading in, the runtime keeps requesting redraws so the animation runs smoothly even when nothing else on screen is changing.

Builder API

#![allow(unused)]
fn main() {
use blinc_layout::prelude::*;
use std::time::Duration;

// Basic lazy loading — no placeholder, just defer decode
img("large-photo.jpg")
    .lazy()
    .w(300.0)
    .h(200.0)

// Solid color placeholder
img("photo.jpg")
    .lazy()
    .placeholder_color(Color::rgba(0.2, 0.2, 0.2, 1.0))
    .w(300.0)
    .h(200.0)

// Gradient (or any Brush) placeholder
img("photo.jpg")
    .lazy()
    .placeholder_brush(Brush::Gradient(Gradient::linear(
        Point::new(0.0, 0.0),
        Point::new(1.0, 1.0),
        Color::rgba(0.4, 0.6, 1.0, 1.0),
        Color::rgba(0.6, 0.4, 1.0, 1.0),
    )))
    .w(300.0)
    .h(200.0)

// Thumbnail / blur-hash placeholder — eagerly preloaded
img("large-photo.jpg")
    .lazy()
    .placeholder_image("thumbnail.jpg")
    .fade_in(Duration::from_millis(300))
    .w(300.0)
    .h(200.0)

// Skeleton shimmer (animated band sweeping left → right)
img("photo.jpg")
    .lazy()
    .skeleton()
    .fade_in(Duration::from_millis(250))
    .w(300.0)
    .h(200.0)

// Cross-fade off — image pops in instantly when ready
img("photo.jpg")
    .lazy()
    .no_fade()
    .w(300.0)
    .h(200.0)
}

Loading strategies

StrategyDescription
Eager (default)Load and decode immediately when the element is created
LazyDefer load until the image’s layout rect intersects the viewport

Placeholder types

PlaceholderDescription
NoneNo placeholder — empty until the bitmap arrives
Color(color)Solid color background
Brush(brush)Any brush — gradients, glass effects, etc.
Image(url)Another image (low-res thumbnail, blur hash). Preloaded on tree build
SkeletonShimmer band animation, no asset required

CSS overrides

Lazy-loading behavior can be overridden from a stylesheet without touching the builder. This is useful for theming galleries or applying defaults to all images that match a class.

.gallery-item {
    loading: lazy;
    image-placeholder-type: skeleton;
    fade-duration: 250ms;
}

.avatar {
    loading: lazy;
    image-placeholder-color: rgba(40, 40, 50, 1.0);
    fade-duration: 200ms;
}

.hero {
    loading: lazy;
    image-placeholder-image: "hero-blur.jpg";
    fade-duration: 400ms;
}
PropertyValuesEffect
loadingeager | lazySwitches the loading strategy
image-placeholder-typenone | color | skeletonSelects which placeholder to draw
image-placeholder-color<color>Solid color placeholder; implies type: color
image-placeholder-image<url>Thumbnail placeholder; implies type: image
fade-duration<time> (200ms, 0.3s)Fade-in length once the bitmap is ready

CSS values override builder values. Use the builder for one-off images and CSS for repeated patterns.

Note: Lazy loading currently applies to raster images only. SVGs are vectorized and rasterized on demand, so deferring their decode rarely pays for itself; if you need a deferred SVG, wrap it in a Stateful and reveal it when needed.


Emoji Images

Render emoji as images at arbitrary sizes using the system emoji font. Emoji images are automatically lazy-loaded for memory efficiency.

#![allow(unused)]
fn main() {
use blinc_layout::image::{emoji, emoji_sized};

// Default size (64px)
emoji("😀")

// Custom size
emoji_sized("🚀", 128.0)

// In a layout
div()
    .flex_row()
    .gap(8.0)
    .child(emoji_sized("👍", 32.0))
    .child(emoji_sized("🎉", 32.0))
    .child(emoji_sized("✨", 32.0))
}

Emoji images use the system color emoji font (Apple Color Emoji on macOS, Segoe UI Emoji on Windows, Noto Color Emoji on Linux).


SVG

Basic SVG

#![allow(unused)]
fn main() {
use blinc_layout::svg::svg;

svg("icons/menu.svg")
    .w(24.0)
    .h(24.0)
}

SVG with Tint

Apply a color tint to monochrome SVGs:

#![allow(unused)]
fn main() {
svg("icons/settings.svg")
    .w(24.0)
    .h(24.0)
    .tint(Color::WHITE)

svg("icons/error.svg")
    .w(20.0)
    .h(20.0)
    .tint(Color::rgba(0.9, 0.3, 0.3, 1.0))
}

SVG Sizing

#![allow(unused)]
fn main() {
// Fixed size
svg(src).w(32.0).h(32.0)

// Square shorthand
svg(src).square(24.0)

// Aspect ratio preserved
svg(src).w(48.0).h_auto()
}

Common Patterns

Avatar Image

#![allow(unused)]
fn main() {
fn avatar(url: &str, size: f32) -> impl ElementBuilder {
    image(url)
        .w(size)
        .h(size)
        .cover()
        .rounded_full()  // Circular
}
}

Icon Button

#![allow(unused)]
fn main() {
use blinc_layout::stateful::stateful;

fn icon_button(icon_path: &str) -> impl ElementBuilder {
    stateful::<ButtonState>()
        .w(40.0)
        .h(40.0)
        .rounded(8.0)
        .flex_center()
        .on_state(|ctx| {
            let bg = match ctx.state() {
                ButtonState::Idle => Color::TRANSPARENT,
                ButtonState::Hovered => Color::rgba(0.2, 0.2, 0.25, 1.0),
                ButtonState::Pressed => Color::rgba(0.15, 0.15, 0.2, 1.0),
                _ => Color::TRANSPARENT,
            };
            div().bg(bg)
        })
        .child(
            svg(icon_path)
                .w(20.0)
                .h(20.0)
                .tint(Color::WHITE)
        )
}
}

Image Card

#![allow(unused)]
fn main() {
fn image_card(image_url: &str, title: &str) -> impl ElementBuilder {
    div()
        .w(300.0)
        .rounded(12.0)
        .overflow_clip()
        .bg(Color::rgba(0.15, 0.15, 0.2, 1.0))
        .child(
            image(image_url)
                .w_full()
                .h(180.0)
                .cover()
        )
        .child(
            div()
                .p(16.0)
                .child(
                    text(title)
                        .size(18.0)
                        .weight(FontWeight::SemiBold)
                        .color(Color::WHITE)
                )
        )
}
}
#![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

  1. Set explicit dimensions - Images need width and height for layout.

  2. Use cover for photos - Fills container nicely without distortion.

  3. Use contain for diagrams - Ensures nothing is cropped.

  4. Tint icons - Use .tint() to match your color scheme.

  5. Use SVG for icons - Scales perfectly at any size.

  6. Optimize images - Use appropriate formats and compression for web.

  7. Use lazy loading for galleries - In scroll containers with many images, use .lazy() to reduce memory usage and improve initial load time.

  8. Use emoji images for large emoji - For emoji larger than ~24px, use emoji_sized() instead of text for crisp rendering.

Audio & Video

The blinc_media crate provides cross-platform audio/video with royalty-free codecs. Media widgets in blinc_layout (behind the media feature) provide player UIs.

Audio Playback

#![allow(unused)]
fn main() {
use blinc_media::{AudioPlayer, AudioSource};

let player = AudioPlayer::new();
player.play(AudioSource::file("music.ogg"));
player.set_volume(0.8);
player.pause();
player.seek(30_000); // seek to 30s
player.resume();

println!("Position: {}ms", player.position_ms());
}

Desktop: Vorbis, WAV, FLAC via rodio. Mobile: platform codecs via native bridge.

Video Playback

#![allow(unused)]
fn main() {
use blinc_media::{VideoPlayer, VideoDecoder};

let mut decoder = VideoDecoder::new();
let player = VideoPlayer::new();

// Decode H.264 NAL units → RGBA frames
if let Some(frame) = decoder.decode_nal(h264_packet) {
    player.push_frame(frame);
}

player.play();
player.seek(10_000);
}

Desktop: OpenH264 (royalty-free). Mobile: platform decoders via native bridge.

Player Trait

Both players implement the shared Player trait:

#![allow(unused)]
fn main() {
use blinc_media::Player;

fn show_status(p: &dyn Player) {
    println!("{} / {} | vol: {}", p.position_ms(), p.duration_ms(), p.volume());
}
}
MethodDescription
play() / pause() / stop()Playback controls
seek(ms)Seek to position
position_ms() / duration_ms()Time tracking
volume() / set_volume(f32)Volume (0.0–1.0)
is_playing() / is_live()State queries

Audio Widget

#![allow(unused)]
fn main() {
use std::rc::Rc;
use blinc_layout::widgets::media::audio_player;

let player = Rc::new(AudioPlayer::new());

// Basic controls
audio_player(Rc::clone(&player)).w_full().into_div()

// With waveform
audio_player(Rc::clone(&player))
    .waveform_data(&samples)
    .w_full()
    .into_div()
}

Video Widget

#![allow(unused)]
fn main() {
use std::rc::Rc;
use blinc_layout::widgets::media::video_player;

let player = Rc::new(VideoPlayer::new());

video_player(Rc::clone(&player))
    .show_dimensions()
    .w_full()
    .h(400.0)
    .into_div()
}

Waveform

Standalone amplitude visualization:

#![allow(unused)]
fn main() {
use blinc_layout::widgets::media::waveform;

waveform(buckets)
    .progress(0.5)
    .played_color(Color::BLUE)
    .unplayed_color(Color::GRAY)
    .w_full().h(60.0)
    .into_div()
}

Shared Controls

MediaControls is generic over Player:

#![allow(unused)]
fn main() {
use blinc_layout::widgets::media::MediaControls;

MediaControls::new(player_rc).class("my-controls").into_div()
}

Layout: [ ▶ ] [ 1:23 / 3:45 ] [ ══seek══ ] [ 80% ]

Live streams: [ ▶ ] [ LIVE ] [ ════════════ ]

Camera & Recording

#![allow(unused)]
fn main() {
use blinc_media::rtc::{CameraStream, CameraConfig, AudioRecorder};

let camera = CameraStream::open(CameraConfig::default());
let frame = camera.latest_frame(); // RGBA Frame

let recorder = AudioRecorder::open(Default::default());
let samples = recorder.latest_samples(); // AudioSamples

drop(camera);   // stops capture
drop(recorder);  // stops recording
}

Frame Utilities

#![allow(unused)]
fn main() {
use blinc_media::{Frame, AudioSamples};

// Video
let small = Frame::from_rgba(data, 640, 480).scale(320, 240);
let gray = small.to_gray();

// Audio
let mono = AudioSamples::from_f32(&pcm, 2, 44100).to_mono();
let resampled = mono.resample(48000);
}

CSS Classes

ClassElement
.blinc-audio-playerAudio container
.blinc-video-playerVideo container
.blinc-audio-waveformWaveform canvas
.blinc-media-controlsControls row
.blinc-media-play-btnPlay/pause
.blinc-media-timeTime display
.blinc-media-live-badgeLIVE indicator
.blinc-media-seek-trackSeek bar
.blinc-media-seek-fillSeek progress
.blinc-media-volumeVolume

Licensing

Desktop uses royalty-free codecs only — no ffmpeg, no patent fees:

CodecLicense
Vorbis, WAV, FLACBSD / Public domain
OpenH264BSD, Cisco covers patents

Mobile uses OS-provided codecs (licensing handled by the OS).

Markdown Rendering

Blinc includes a built-in markdown renderer that converts CommonMark + GFM markdown to native layout elements.

Basic Usage

#![allow(unused)]
fn main() {
use blinc_layout::markdown::markdown;

// Render markdown to a Div
let content = markdown(r#"
Hello World

This is **bold** and *italic* text.

- List item 1
- List item 2
"#);

// Use in your layout
div()
    .flex_col()
    .child(content)
}

Themes

The renderer supports light and dark themes:

#![allow(unused)]
fn main() {
use blinc_layout::markdown::{markdown, markdown_light, markdown_with_config, MarkdownConfig};

// Dark theme (default) - for dark backgrounds
let dark_content = markdown("# Dark Theme");

// Light theme - for white/light backgrounds
let light_content = markdown_light("# Light Theme");

// Custom configuration
let custom = markdown_with_config("# Custom", MarkdownConfig {
    h1_size: 36.0,
    body_size: 16.0,
    ..MarkdownConfig::default()
});
}

Supported Elements

Text Formatting

MarkdownResult
**bold**bold text
*italic*italic text
~~strikethrough~~strikethrough text
`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#"
![Alt text](path/to/image.png)

![Remote image](https://example.com/photo.jpg)
"#)
}

Configuration

Customize the renderer with MarkdownConfig:

#![allow(unused)]
fn main() {
use blinc_layout::markdown::{markdown_with_config, MarkdownConfig};
use blinc_core::Color;

let config = MarkdownConfig {
    // Typography sizes
    h1_size: 32.0,
    h2_size: 28.0,
    h3_size: 24.0,
    h4_size: 20.0,
    h5_size: 18.0,
    h6_size: 16.0,
    body_size: 16.0,
    code_size: 14.0,

    // Colors
    text_color: Color::WHITE,
    heading_color: Color::WHITE,
    link_color: Color::rgba(0.4, 0.6, 1.0, 1.0),
    code_bg: Color::rgba(0.1, 0.1, 0.12, 1.0),
    code_text: Color::rgba(0.9, 0.6, 0.3, 1.0),

    // Spacing
    paragraph_spacing: 16.0,
    heading_margin_top: 24.0,
    heading_margin_bottom: 12.0,

    // Lists
    list_indent: 24.0,
    list_item_spacing: 4.0,

    ..Default::default()
};

let content = markdown_with_config("# Custom Styled", config);
}

Preset Themes

#![allow(unused)]
fn main() {
// Dark theme (default) - white text on dark backgrounds
let dark = MarkdownConfig::default();

// Light theme - dark text on light backgrounds
let light = MarkdownConfig::light();
}

Live Editor Example

A full markdown editor with live preview is available in the examples:

cargo run -p blinc_app_examples --example markdown_demo --features windowed

This demonstrates:

  • TextArea for markdown source editing
  • Live preview with markdown_light()
  • Stateful reactive updates on text change

HTML Entities

The renderer automatically decodes HTML entities in text:

#![allow(unused)]
fn main() {
markdown(r#"
&copy; 2025 &mdash; All rights reserved

Temperature: 72&deg;F

Price: &euro;99.99
"#)
}

Common entities: &amp; (&), &lt; (<), &gt; (>), &quot; ("), &nbsp; (non-breaking space), &copy; (©), &trade; (), and many more.

Best Practices

  1. Use markdown_light() for light backgrounds - The default theme assumes dark backgrounds.

  2. 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))
    }
  3. Set container width - Markdown content respects parent width:

    #![allow(unused)]
    fn main() {
    div()
        .w(800.0)
        .child(markdown(content))
    }
  4. Code blocks need height - For syntax highlighting to render properly, ensure the container has adequate height.

  5. Images need explicit dimensions - While images will render, they work best when the markdown container has width constraints.

Overview

blinc_canvas_kit is the layer that turns Blinc’s raw GPU-drawing primitives into an authoring surface. It sits on top of the canvas() element and provides four building blocks:

APIWhen to reach for it
SketchesPer-frame immediate-mode drawing with persistent state — particle systems, generative art, live visualisations.
PlayersTime-based animation sources (Lottie, Rive, custom scene formats) driven by an external t.
CanvasKitInteractive 2D canvases: pan, zoom, hit-testing, pointer / drag / selection callbacks.
SceneKit3D3D scene authoring: orbit camera, lights, environment maps, mesh draw, gizmos.

All four share a common principle: the kit owns the per-frame render loop and whatever persistent state the drawing needs, then exposes a small trait (Sketch, Player) or a handle (CanvasKit, SceneKit3D) you feed into a Div tree. State survives UI rebuilds via use_state_keyed, so layout changes, hot reload, and route transitions don’t reset counters, particle systems, camera poses, or asset uploads.

Reach for raw canvas() only when you want a one-shot static render with no animation loop and no persistent state — for example, a chart drawn from a one-time computation.

Import surface

Everything in this chapter lives under the prelude:

#![allow(unused)]
fn main() {
use blinc_canvas_kit::prelude::*;
}

The prelude re-exports Sketch, SketchContext, Painter2D, Player, sketch, CanvasKit, SceneKit3D, OrbitCamera, and the relevant event types. Explicit imports work too — everything is in blinc_canvas_kit or blinc_canvas_kit::sketch.

Animation cadence

Every kit runs its draw callback at the host’s redraw cadence (typically vsync: 60 / 120 Hz) by requesting another frame at the end of each render. There is no opt-out from inside a Sketch — if you want static output, use plain canvas() directly. For deterministic playback (recording frames, scrubbing), drive a Player from outside a sketch and pass synthesised t values.

Sketches

A sketch is a struct that owns its own animation state plus a draw() method called every frame. Implement the Sketch trait on your struct, then mount it into a Div tree with sketch(key, impl).

The Sketch trait

#![allow(unused)]
fn main() {
use blinc_canvas_kit::prelude::*;
use blinc_core::layer::Color;

struct Bouncer {
    x: f32,
    vx: f32,
}

impl Sketch for Bouncer {
    fn draw(&mut self, ctx: &mut SketchContext<'_>, _t: f32, dt: f32) {
        self.x += self.vx * dt;
        if self.x < 0.0 || self.x + 40.0 > ctx.width {
            self.vx = -self.vx;
        }

        let mut p = ctx.painter();
        p.fill(Color::WHITE).no_stroke();
        p.rect(self.x, 100.0, 40.0, 40.0);
    }
}
}

The trait has two methods:

MethodCalledPurpose
setup(&mut self, ctx)Once before the first drawAsset preload, GPU upload, one-shot layout. Default: no-op.
draw(&mut self, ctx, t, dt)Every frameMutate state; emit draw calls. t = seconds since the sketch started; dt = seconds since the previous frame.

Sketches must be Send + 'static — their state lives behind an Arc<Mutex<...>> in Blinc’s persistent state bag.

Mounting: sketch()

#![allow(unused)]
fn main() {
fn build_ui() -> impl ElementBuilder {
    div()
        .w(600)
        .h(400)
        .child(sketch("bouncer", Bouncer { x: 0.0, vx: 200.0 }))
}
}

The key identifies the sketch for state persistence. Every sketch("bouncer", ...) with the same key reuses the same persisted state across rebuilds — hot reload, layout changes, route transitions all preserve counters, particle systems, and elapsed time. Pick unique keys per instance.

Wrap the returned Div in a sized container (.w(...), .h(...), .aspect_ratio(...), or a flex parent) to control bounds. The sketch fills its parent.

SketchContext

The per-frame context exposes the canvas size, a frame counter, and three drawing entry points:

#![allow(unused)]
fn main() {
pub struct SketchContext<'a> {
    pub width: f32,        // Canvas width in layout units
    pub height: f32,       // Canvas height in layout units
    pub frame_count: u64,  // Frames drawn since setup()
    // ...
}
}
MethodReturnsUse for
ctx.painter()Painter2D<'_>Stateful immediate-mode drawing (Processing-style)
ctx.draw_context()&mut dyn DrawContextFull GPU access: gradients, glass, clips, 3D, images, text
ctx.play(&mut player, rect, t)()Forward to a Player

painter() and draw_context() each mutably borrow the underlying DrawContext — drop one before calling the other.

Painter2D

The painter holds a current fill, stroke, and transform stack so you don’t repeat those arguments on every primitive call.

Fill & stroke state

#![allow(unused)]
fn main() {
let mut p = ctx.painter();

p.fill(Color::RED).no_stroke();     // Red fill, no outline
p.rect(10.0, 10.0, 100.0, 50.0);

p.stroke(Color::BLACK, 2.0);         // Add a 2px black stroke
p.circle(200.0, 200.0, 40.0);

p.no_fill().stroke(Color::BLUE, 1.0);
p.line(0.0, 0.0, 300.0, 300.0);
}

Transform stack

push() / pop() bracket grouped transforms. A single pop() undoes every transform pushed since its matching push():

#![allow(unused)]
fn main() {
p.push();
p.translate(100.0, 100.0);
p.rotate(std::f32::consts::FRAC_PI_4);
p.scale(2.0, 2.0);
p.rect(-10.0, -10.0, 20.0, 20.0);   // All three transforms active
p.pop();                              // All three transforms undone
}

Calling translate / rotate / scale without a surrounding push() still pushes onto the underlying stack, but pop() can’t undo them. Always use the bracketed pattern for scoped transforms.

When Painter2D’s operations aren’t enough — gradients, glass, clips, 3D, images, text — drop the painter and reach for ctx.draw_context() directly. See Canvas Drawing for the full DrawContext surface.

Players

The Player trait is the contract for time-based animation sources (Lottie, Rive, custom scene files). Implement it once; the same sketch can then drive any player without knowing the format.

The trait

#![allow(unused)]
fn main() {
pub trait Player: Send + 'static {
    fn duration(&self) -> Option<f32>;
    fn draw_at(&mut self, ctx: &mut SketchContext<'_>, rect: Rect, t: f32);
    fn seek(&mut self, _t: f32) {}
    fn set_playing(&mut self, _playing: bool) {}
}
}
MethodDefaultPurpose
duration()requiredTotal playback duration in seconds. None signals content that plays indefinitely (procedural, live, user-controlled).
draw_at(ctx, rect, t)requiredRender one frame at time t into rect. Interpolate the scene at t, dispatch draw calls into ctx.
seek(t)no-opSeek internal playback to t. Players that derive every frame from the incoming t don’t need to override.
set_playing(playing)no-opPause / resume. Paused players should render their frozen pose and ignore t in draw_at.

Playing a Lottie scene

blinc_lottie::LottiePlayer implements Player. Wrap it in a sketch to run at any size:

#![allow(unused)]
fn main() {
use blinc_app::prelude::*;
use blinc_canvas_kit::prelude::*;
use blinc_core::{Color, Rect};
use blinc_lottie::LottiePlayer;

const LOTTIE_JSON: &str = include_str!("assets/my_animation.json");

struct Loader {
    player: LottiePlayer,
}

impl Sketch for Loader {
    fn draw(&mut self, ctx: &mut SketchContext<'_>, t: f32, _dt: f32) {
        let size = ctx.width.min(ctx.height);
        let x = (ctx.width - size) * 0.5;
        let y = (ctx.height - size) * 0.5;
        ctx.play(&mut self.player, Rect::new(x, y, size, size), t);
    }
}

fn build_ui() -> impl ElementBuilder {
    let player = LottiePlayer::from_json(LOTTIE_JSON).expect("parse Lottie");
    div()
        .w_full()
        .h_full()
        .bg(Color::WHITE)
        .child(sketch("lottie", Loader { player }))
}
}

ctx.play(&mut player, rect, t) is a thin forwarder over Player::draw_at — provided so sketches holding a player on self don’t hit borrow-checker friction when draw also reads other self fields.

Lottie specifically supports both plain JSON (from_json) and .lottie archives (from_dotlottie_bytes, requires the dotlottie feature). See the blinc_lottie crate for asset-loading variants.

Writing your own player

Anything that can resolve a pose from a float time implements Player. A minimal example: a player that renders an orbiting dot.

#![allow(unused)]
fn main() {
use blinc_canvas_kit::prelude::*;
use blinc_core::{Color, CornerRadius, Brush, Rect};

struct Orbit;

impl Player for Orbit {
    fn duration(&self) -> Option<f32> { None }  // plays forever

    fn draw_at(&mut self, ctx: &mut SketchContext<'_>, rect: Rect, t: f32) {
        let cx = rect.x() + rect.width() * 0.5;
        let cy = rect.y() + rect.height() * 0.5;
        let r = rect.width().min(rect.height()) * 0.4;
        let a = t * std::f32::consts::TAU * 0.5;
        let x = cx + r * a.cos() - 8.0;
        let y = cy + r * a.sin() - 8.0;

        ctx.draw_context().fill_rect(
            Rect::new(x, y, 16.0, 16.0),
            CornerRadius::uniform(8.0),
            Brush::Solid(Color::WHITE),
        );
    }
}
}

Drop Orbit into any sketch via ctx.play(&mut orbit, rect, t) and it composes with other players, sketches, and UI in the same frame.

CanvasKit: Interactive Canvases

When a sketch needs hit-testing, pointer / keyboard events, pan, or zoom, pair it with CanvasKit. Register hit regions by ID inside draw, install callbacks once on the kit itself, and wire pan/zoom automatically.

Hit regions

#![allow(unused)]
fn main() {
use blinc_canvas_kit::prelude::*;

struct Scene {
    kit: CanvasKit,
    hovered: Option<String>,
}

impl Sketch for Scene {
    fn setup(&mut self, _ctx: &mut SketchContext<'_>) {
        self.kit.on_element_click(|ev| {
            println!("clicked region: {}", ev.id);
        });

        // Note: hover callbacks run on pointer enter / leave. Persist the
        // hovered id into sketch state to drive per-frame highlight logic.
        let hovered = /* reference-counted handle back into sketch state */;
        self.kit.on_element_hover(move |ev| {
            // update `hovered` here
        });
    }

    fn draw(&mut self, ctx: &mut SketchContext<'_>, _t: f32, _dt: f32) {
        // Register hit regions each frame (IDs flow into the callbacks).
        self.kit.hit_rect("box-a", Rect::new(50.0, 50.0, 100.0, 100.0));
        self.kit.hit_rect("box-b", Rect::new(200.0, 50.0, 100.0, 100.0));

        // Draw — pick color based on whatever the hover callback stashed.
    }
}
}

Callbacks

All installed on the CanvasKit once (typically in setup or at construction):

CallbackFires on
on_element_click(cb)Click on a hit region
on_element_hover(cb)Enter / leave a hit region
on_element_drag(cb)Drag a hit region
on_element_drag_end(cb)Drag release
on_selection_change(cb)Multi-select / marquee changes

Each callback receives a CanvasEvent carrying the region id, the content-space pointer position, and the triggering EventContext.

Built-in gestures

CanvasKit wires the following automatically once it’s in a sketch:

  • Pan — drag on empty background
  • Zoom — scroll wheel (content-space)
  • Marquee select — drag from empty background with shift / modifier
  • Grid snap — configurable via kit.snap_rect(rect) / kit.snap_point(p)

Tune sensitivity via the builder methods before mounting:

#![allow(unused)]
fn main() {
let kit = CanvasKit::new("scene")
    .with_drag_sensitivity(1.0)
    .with_zoom_sensitivity(0.1)
    .with_momentum_decay(0.92);
}

Content-space vs screen-space

hit_rect and hit_test operate on content-space coordinates (pre-pan, pre-zoom). The kit transforms pointer events and render bounds into content space before dispatching — you author as if the canvas were infinite and at 1:1 zoom. Use kit.is_visible(rect) to cull content-space rects against the current viewport before drawing expensive primitives.

Full example

See the Canvas Kit Interactive example for a complete walkthrough with pan, zoom, hover feedback, drag, marquee select, and a HUD overlay.

Bundled input routing

To pipe every event the kit receives (pointer, scroll, key) into a single callback — useful for bridging into blinc_input::InputState::record or custom routing — attach .on_canvas_events(|e| ...) to the Div returned by sketch():

#![allow(unused)]
fn main() {
use blinc_canvas_kit::sketch::SketchEvents;

sketch("scene", Scene { kit: CanvasKit::new("scene") })
    .on_canvas_events(|ev| input.record(ev))
}

SceneKit3D

SceneKit3D is the 3D counterpart to CanvasKit. It wraps an orbit camera, a set of lights, an environment map, and a mesh list into a single handle you can mount as a Div. Ideal for model viewers, glTF playback, and any 3D content where you don’t want to hand-wire matrix math and event plumbing.

For a lower-level intro (raw MeshData, shaders, materials), see 3D Rendering.

Minimal example

#![allow(unused)]
fn main() {
use blinc_canvas_kit::prelude::*;
use blinc_core::{Material, MeshData};
use std::sync::Arc;

fn build_ui() -> impl ElementBuilder {
    let kit = SceneKit3D::new("viewer")
        .with_environment(generate_studio_environment(256))
        .with_camera(OrbitCamera::default()
            .with_distance(5.0)
            .with_elevation(0.2));

    // Load a mesh — replace with glTF loading in real apps.
    let mesh: Arc<MeshData> = load_my_mesh();
    kit.add_mesh(mesh);

    div()
        .w_full()
        .h_full()
        .child(kit.element_auto())
}
}

element_auto() returns a Div that draws every registered mesh each frame, wires orbit (drag) + zoom (scroll) to the camera, and redraws continuously. element(|ctx, bounds| ...) is the manual equivalent if you want to mix in custom primitives around the scene.

Orbit camera

OrbitCamera is a spherical-coordinate camera around a target point:

#![allow(unused)]
fn main() {
let cam = OrbitCamera::default()
    .with_distance(5.0)      // Radius from target
    .with_azimuth(0.5)       // Horizontal angle, radians
    .with_elevation(0.3)     // Vertical angle, radians
    .with_target(Vec3::ZERO) // Look-at point
    .with_fov_y(60f32.to_radians());
}

Runtime mutation via the kit:

#![allow(unused)]
fn main() {
kit.update_camera(|cam| {
    cam.orbit(dx_rad, dy_rad);  // Mouse delta in radians
    cam.zoom(1.1);              // > 1 zooms out, < 1 zooms in
});
}

kit.camera() snapshots the current camera; kit.camera_signal() gives a signal id you can subscribe to for external UI synced to camera state.

Lights

Lights are added via with_light(...) (builder) or set_lights(vec) (runtime):

#![allow(unused)]
fn main() {
use blinc_core::Light;

let kit = SceneKit3D::new("viewer")
    .with_light(Light::directional([0.5, -1.0, 0.3], [1.0, 0.95, 0.9], 1.2))
    .with_light(Light::point([2.0, 1.0, 2.0], [0.4, 0.7, 1.0], 5.0));
}

See 3D Rendering for the full Light API (directional, point, spot, with shadow toggles).

Environment maps

Two helpers produce cubemaps ready to feed into with_environment:

#![allow(unused)]
fn main() {
// Procedural studio lighting (gradient sky + soft ground)
let env = generate_studio_environment(256);

// HDRI (Radiance `.hdr` file) → cubemap + irradiance + specular IBL
let hdr_bytes = std::fs::read("studio.hdr")?;
let env = generate_hdri_environment(&hdr_bytes, 512);

let kit = SceneKit3D::new("viewer").with_environment(env);
}

Shortcut: with_hdri(hdr_bytes, face_size) does the read + decode in one call. Both set_environment and set_hdri are available for async loading — spawn a background task, build the EnvironmentData, then apply.

Mesh management

MethodPurpose
kit.add(geometry, material)Add from (Vec<Vertex>, Vec<u32>) + material. Returns a MeshHandle.
kit.add_mesh(Arc<MeshData>)Add a pre-built mesh (glTF load, procedural generator). Returns a MeshHandle.
kit.set_position(handle, pos)Update mesh world-space translation.
kit.set_rotation(handle, euler)Update Euler rotation (radians).
kit.set_scale(handle, scale)Update scale.
kit.set_visible(handle, visible)Toggle without removing.

All mutations are &self — the kit is Clone and Send, so background loader threads can push meshes into it as assets resolve. The render closure reads the latest state each frame.

Input wiring

For key-driven camera moves (WASD fly-through, etc.), pair the kit with blinc_input::InputState:

#![allow(unused)]
fn main() {
use blinc_input::InputState;

let input = InputState::new();
let kit = SceneKit3D::new("viewer").with_input(&input);
}

with_input automates the two error-prone pieces users tend to forget:

  • capture_input on the outer viewport Div so key/pointer/scroll feed the state
  • InputState::frame_end() at the end of every paint pass so edge-triggered queries (is_key_just_pressed) stay one-frame-scoped

Routing & Navigation

The blinc_router crate provides cross-platform routing with path matching, navigation history, guards, page transitions, and deep linking.

Setup

#![allow(unused)]
fn main() {
use blinc_router::{RouterBuilder, Route, PageTransition};

let router = RouterBuilder::new()
    .route(Route::new("/").name("home").view(home_page))
    .route(Route::new("/users").name("users").view(users_page)
        .child(Route::new("/:id").name("user").view(user_detail)))
    .route(Route::new("/settings")
        .view(settings_page)
        .transition(PageTransition::modal()))
    .not_found(not_found_page)
    .build();
}
#![allow(unused)]
fn main() {
// Push (adds to history)
router.push("/users/42");

// Named route with params
router.push_named("user", &[("id", "42")]);

// Replace (no history entry)
router.replace("/login");

// Back / Forward
router.back();
router.forward();

// Check state
router.can_go_back();
router.current_path();
router.params().get("id");
router.query().get("page");
}

Route Outlet

Place router.outlet() where the page content should render:

#![allow(unused)]
fn main() {
fn build_ui(ctx: &WindowedContext) -> impl ElementBuilder {
    div().flex_col()
        .child(nav_bar(&router))
        .child(router.outlet()) // Current route renders here
}
}

use_router() Hook

Inside route views, use_router() returns the active router:

#![allow(unused)]
fn main() {
fn user_detail(ctx: RouteContext) -> Div {
    let router = use_router(); // Same as ctx.router
    let id = ctx.params.get("id").unwrap_or("?");

    div()
        .child(text(&format!("User #{}", id)))
        .child(
            div().on_click(move |_| router.back())
                .child(text("Back"))
        )
}
}

Nested routers work automatically — use_router() returns whichever router’s outlet() is currently building.

Page Transitions

Per-route transitions using Blinc’s animation system:

#![allow(unused)]
fn main() {
Route::new("/settings")
    .view(settings_page)
    .transition(PageTransition::slide())      // iOS push style
    .transition(PageTransition::fade())       // Crossfade
    .transition(PageTransition::modal())      // Slide up/down
    .transition(PageTransition::scale())      // Scale in/out
    .transition(PageTransition::none())       // Instant

// Custom with spring physics
    .transition(PageTransition::slide().with_spring(SpringConfig::bouncy()))
}

Protect routes with guards that allow, redirect, or reject:

#![allow(unused)]
fn main() {
use blinc_router::{NavigationGuard, GuardResult};
use std::sync::Arc;

let auth_guard: NavigationGuard = Arc::new(|_from, _to| {
    if is_authenticated() {
        GuardResult::Allow
    } else {
        GuardResult::Redirect("/login".into())
    }
});

RouterBuilder::new()
    .route(Route::new("/dashboard").view(dashboard).guard(auth_guard))
    .build();
}

Deep Linking

Deep linking is automatic — just build a router and it works on all platforms. RouterBuilder::build() auto-registers the deep link handler and back button.

#![allow(unused)]
fn main() {
// That's it — no platform-specific setup needed in Rust
let router = RouterBuilder::new()
    .route(Route::new("/users/:id").view(user_page))
    .build();
// Deep links to myapp://host/users/42 automatically navigate
}

Platform Configuration

Android — add intent filters in AndroidManifest.xml:

<intent-filter>
    <action android:name="android.intent.action.VIEW" />
    <data android:scheme="myapp" />
</intent-filter>

iOS — add URL types in Info.plist:

<key>CFBundleURLTypes</key>
<array>
    <dict>
        <key>CFBundleURLSchemes</key>
        <array><string>myapp</string></array>
    </dict>
</array>

Desktop — register a custom URL scheme with the OS:

macOS (Info.plist):

<key>CFBundleURLTypes</key>
<array>
    <dict>
        <key>CFBundleURLSchemes</key>
        <array><string>myapp</string></array>
    </dict>
</array>

Windows (registry, set up by installer):

HKEY_CLASSES_ROOT\myapp\shell\open\command = "C:\path\to\myapp.exe" "--deep-link=%1"

Linux (.desktop file):

MimeType=x-scheme-handler/myapp
Exec=myapp --deep-link=%u

CLI fallback:

myapp --deep-link=myapp://host/users/42

How It Works

  1. RouterBuilder::build() registers a global deep link handler
  2. Platform runners auto-dispatch incoming URIs to the handler
  3. The router parses the URI and calls push(path)
  4. No user code needed beyond building the router

System Back Button

Also automatic — RouterBuilder::build() registers a back button handler.

  • Android: system back button navigates back if the router has history
  • Desktop: Key::Back dispatches through the back handler stack
  • If at the root route, the event propagates (app exits normally)

Route Matching

Express-style path patterns:

PatternExampleMatches
Static/aboutExact match
Parameter/users/:id/users/42{id: "42"}
Wildcard/files/*path/files/a/b/c{path: "a/b/c"}
Nestedparent + child/users + /:id/users/42
Queryany path/search?q=hello{q: "hello"}

Named Routes

Look up routes by name for type-safe navigation:

#![allow(unused)]
fn main() {
// Check if a named route exists
router.path_for("user"); // Some("/users/:id")

// Navigate with params
router.push_named("user", &[("id", "42")]); // → /users/42

// Check if a path matches
router.has_route("/users/42"); // true
}

Tab Navigator

Use the tabs() component from blinc_cn with the router’s current path as the active tab:

#![allow(unused)]
fn main() {
use blinc_cn::tabs;

fn app_shell(router: &Router) -> Div {
    // Track active tab via router path
    let active_tab = ctx.use_state_keyed("tab", || router.current_path());

    div().flex_col().w_full().h_full()
        // Content area — router outlet
        .child(router.outlet().flex_grow())
        // Bottom tab bar
        .child(
            tabs(&active_tab)
                .tab("Home", "/", {
                    let r = router.clone();
                    move || r.push("/")
                })
                .tab("Search", "/search", {
                    let r = router.clone();
                    move || r.push("/search")
                })
                .tab("Profile", "/profile", {
                    let r = router.clone();
                    move || r.push("/profile")
                })
        )
}
}

Each tab click calls router.push() which updates the outlet. The tab state stays in sync with the route.

Stack Navigator (Page Stack)

The router maintains a page stack — pages persist in the tree when new pages are pushed on top. Suspended pages have input disabled and are hidden, but their state (scroll position, form values, etc.) is preserved.

#![allow(unused)]
fn main() {
// Renders the page stack — active page visible, suspended pages preserved
router.outlet()
}

When you router.push("/details"):

  1. The current page becomes Suspended (opacity 0, pointer_events_none)
  2. The new page is pushed as Active on top

When you router.back():

  1. The top page is removed from the stack
  2. The page below becomes Active again (with preserved state)

Page state

#![allow(unused)]
fn main() {
use blinc_router::PageState;

let pages = router.page_stack();
for page in &pages {
    match page.state {
        PageState::Active => println!("Visible: {}", page.route.path),
        PageState::Suspended => println!("Hidden: {}", page.route.path),
    }
}
}

Entry/exit animations

Use motion() containers inside route views for animated transitions:

#![allow(unused)]
fn main() {
fn user_detail(ctx: RouteContext) -> Div {
    motion()
        .slide_in(SlideDirection::Right, 300)
        .child(
            div().w_full().h_full()
                .child(text(&format!("User #{}", ctx.params.get("id").unwrap_or("?"))))
                .child(
                    div().on_click({
                        let r = ctx.router.clone();
                        move |_| r.back()
                    })
                    .child(text("Back"))
                )
        )
}
}

Nested Route Stacks

Layout routes can contain their own scoped router for sub-navigation. use_router() automatically returns the innermost router:

#![allow(unused)]
fn main() {
fn dashboard_layout(ctx: RouteContext) -> Div {
    // Create a sub-router for dashboard tabs
    let sub_router = RouterBuilder::new()
        .route(Route::new("/").view(dashboard_overview))
        .route(Route::new("/analytics").view(analytics))
        .route(Route::new("/settings").view(settings))
        .initial(&ctx.path) // Start at current sub-path
        .build();

    div().flex_row().w_full().h_full()
        .child(dashboard_sidebar(&sub_router))
        .child(sub_router.outlet()) // Nested outlet — use_router() returns sub_router here
}
}

Bottom Sheet Navigation

Use sheet() from blinc_cn for modal-like navigation that slides up from the bottom:

#![allow(unused)]
fn main() {
use blinc_cn::sheet;

fn show_details(router: &Router, item_id: &str) {
    // Navigate to detail route
    router.push(&format!("/items/{}", item_id));

    // Or show as a bottom sheet overlay
    sheet()
        .title("Item Details")
        .content(move || {
            let router = use_router();
            router.outlet() // Render the matched route inside the sheet
        })
        .show();
}
}

For gesture-dismissable sheets on mobile, the sheet component handles the swipe-down gesture automatically. On dismiss, call router.back():

#![allow(unused)]
fn main() {
sheet()
    .on_close({
        let r = router.clone();
        move || r.back()
    })
    .content(|| detail_view())
    .show();
}
PatternWidgetRouter Integration
Page navigationrouter.outlet()Direct — renders current route
Tab barblinc_cn::tabs()Tab clicks call router.push()
Stack with animationsstack() + motion()Wrap route views in motion containers
Bottom sheetblinc_cn::sheet()Content renders router.outlet(), dismiss calls router.back()
Drawer / sidebarblinc_cn::drawer()Navigation links call router.push()
Back buttonAuto-registeredRouterBuilder::build() wires it
Deep linksAuto-registeredPlatform dispatches to router automatically

Multi-Window Support

Blinc supports multiple windows on desktop platforms. Each window has its own UI tree, event router, and rendering surface while sharing the GPU device and animation scheduler.

Opening Windows

Use open_window_with() to create a new window with a custom UI builder:

#![allow(unused)]
fn main() {
use blinc_app::windowed::open_window_with;

open_window_with(
    WindowConfig::new("Settings")
        .size(400, 300)
        .center(),
    |ctx| {
        div()
            .w(ctx.width)
            .h(ctx.height)
            .bg(Color::rgb(0.1, 0.1, 0.15))
            .child(text("Settings").size(24.0).color(Color::WHITE))
    },
);
}

The builder closure receives &mut WindowedContext with the window’s dimensions and is called each frame when the tree needs rebuilding.

Window Configuration

#![allow(unused)]
fn main() {
WindowConfig::new("My Window")
    .size(800, 600)              // Initial size
    .min_size(400, 300)          // Minimum dimensions
    .max_size(1920, 1080)        // Maximum dimensions
    .position(100, 100)          // Initial position
    .center()                    // Center on screen
    .resizable(true)             // Allow resizing
    .decorations(false)          // Frameless window
    .transparent(true)           // Transparent background
    .always_on_top(true)         // Stay above other windows
    .modal()                     // Block input to other windows
}

Modal windows block input to all other application windows until dismissed:

#![allow(unused)]
fn main() {
open_window_with(
    WindowConfig::new("Confirm")
        .size(360, 200)
        .center()
        .resizable(false)
        .modal(),
    |ctx| {
        let close = ctx.close_callback();
        div()
            .w(ctx.width).h(ctx.height)
            .child(text("Are you sure?"))
            .child(
                div().on_click(move |_| close())
                    .child(text("OK"))
            )
    },
);
}

Custom Title Bars

For frameless windows, use .drag_region() to create a draggable title bar, and per-window callbacks for window controls:

#![allow(unused)]
fn main() {
open_window_with(
    WindowConfig::new("").size(400, 300).decorations(false),
    |ctx| {
        let drag = ctx.drag_callback();
        let minimize = ctx.minimize_callback();
        let maximize = ctx.maximize_callback();
        let close = ctx.close_callback();

        div()
            .w(ctx.width).h(ctx.height)
            .flex_col()
            // Custom title bar
            .child(
                div().w_full().h(36.0)
                    .flex_row().items_center()
                    // Drag zone (sibling of buttons, not parent)
                    .child(
                        div().flex_grow().h_full()
                            .on_mouse_down(move |_| drag())
                            .child(text("My App"))
                    )
                    // Window controls
                    .child(div().on_click(move |_| minimize()).child(text("-")))
                    .child(div().on_click(move |_| maximize()).child(text("+")))
                    .child(div().on_click(move |_| close()).child(text("x")))
            )
            // Content
            .child(div().flex_grow().child(text("Content")))
    },
);
}

Important: Make the drag zone and control buttons siblings (not parent-child) to prevent event bubbling from buttons triggering the drag.

Window State Persistence

Save and restore window position/size across launches:

#![allow(unused)]
fn main() {
use blinc_app::window_state::{WindowStateStore, SavedWindowState};

let store = WindowStateStore::new("my_app");

// Load saved state
let mut config = WindowConfig::default();
if let Some(saved) = store.load("main") {
    config = saved.apply_to(config);
}

// Save state on close
store.save("main", &SavedWindowState {
    x: 100, y: 200, width: 800, height: 600, maximized: false,
});
}

Per-Window Callbacks

Each WindowedContext provides window-specific action callbacks:

MethodDescription
ctx.close_callback()Returns Arc<dyn Fn()> that closes THIS window
ctx.drag_callback()Returns Arc<dyn Fn()> that starts OS drag
ctx.minimize_callback()Returns Arc<dyn Fn()> that minimizes
ctx.maximize_callback()Returns Arc<dyn Fn()> that toggles maximize
ctx.close()Close this window directly
ctx.minimize()Minimize directly
ctx.maximize()Toggle maximize directly
ctx.open_window(config)Open a new window

System Integration

Blinc provides APIs for common desktop system features: file dialogs, system tray, notifications, drag-and-drop, and global keyboard shortcuts.

File Dialogs

Open, save, and folder picker dialogs via the rfd crate:

#![allow(unused)]
fn main() {
use blinc_app::dialog::{open_file, save_file, pick_folder, FileFilter};

// Open a file
if let Some(path) = open_file()
    .title("Open Image")
    .filter(FileFilter::new("Images").ext("png").ext("jpg"))
    .filter(FileFilter::new("All Files").ext("*"))
    .pick()
{
    println!("Selected: {}", path.display());
}

// Open multiple files
let paths = open_file()
    .title("Select Files")
    .filter(FileFilter::new("Rust").ext("rs"))
    .pick_many();

// Save dialog
if let Some(path) = save_file()
    .title("Save As")
    .file_name("untitled.txt")
    .filter(FileFilter::new("Text").ext("txt"))
    .save()
{
    println!("Save to: {}", path.display());
}

// Folder picker
if let Some(dir) = pick_folder()
    .title("Choose Directory")
    .pick()
{
    println!("Directory: {}", dir.display());
}
}

System Tray

Create a tray icon with a context menu for background apps:

#![allow(unused)]
fn main() {
use blinc_app::tray::{TrayIconBuilder, TrayMenuItem};

let _tray = TrayIconBuilder::new()
    .tooltip("My App v1.0")
    .menu(vec![
        TrayMenuItem::item("Show Window", || {
            // bring window to front
        }),
        TrayMenuItem::separator(),
        TrayMenuItem::submenu("Recent", vec![
            TrayMenuItem::item("File 1", || {}),
            TrayMenuItem::item("File 2", || {}),
        ]),
        TrayMenuItem::separator(),
        TrayMenuItem::item("Quit", || std::process::exit(0)),
    ])
    .build();
// Keep `_tray` alive — dropping it removes the icon
}

You can provide a custom icon:

#![allow(unused)]
fn main() {
let rgba = vec![100, 150, 255, 255].repeat(32 * 32); // 32x32 blue icon
TrayIconBuilder::new()
    .icon_rgba(rgba, 32, 32)
    .tooltip("My App")
    .build();
}

Notifications

Send native desktop notifications:

#![allow(unused)]
fn main() {
use blinc_app::notify::Notification;

Notification::new("Download Complete")
    .body("Your file has been saved to ~/Downloads")
    .show();
}

Drag and Drop

Window-Level

Register a global file drop handler:

#![allow(unused)]
fn main() {
use blinc_app::dnd::{on_file_drop, DropEvent};

on_file_drop(|event| match event {
    DropEvent::Hovered(paths) => println!("Dragging: {:?}", paths),
    DropEvent::Dropped(paths) => {
        for path in paths {
            println!("Dropped: {}", path.display());
        }
    }
    DropEvent::Cancelled => println!("Drag cancelled"),
});
}

Element-Level

Make any element a drop target:

#![allow(unused)]
fn main() {
div()
    .w(300.0).h(200.0)
    .bg(Color::rgb(0.15, 0.15, 0.2))
    .rounded(8.0)
    .on_file_drop(|ctx| {
        println!("File dropped on this element!");
    })
    .on_file_drag_over(|ctx| {
        // Show visual feedback
    })
    .on_file_drag_leave(|ctx| {
        // Remove visual feedback
    })
    .child(text("Drop files here"))
}

Global Keyboard Shortcuts

Register system-wide hotkeys that work even when the app isn’t focused:

#![allow(unused)]
fn main() {
use blinc_app::hotkey::GlobalHotkey;

// Active until `_hotkey` is dropped
let _hotkey = GlobalHotkey::new("Ctrl+Shift+P", || {
    println!("Global shortcut triggered!");
});

// macOS uses Cmd
let _hotkey2 = GlobalHotkey::new("Cmd+Shift+Space", || {
    println!("Quick search!");
});
}

Accelerator format: Ctrl, Shift, Alt, Cmd/Super + key name (e.g., A, F1, Space, Enter).

Element Query API

The Element Query API provides programmatic access to elements in the UI tree, enabling imperative operations like scrolling, focusing, reading bounds, and triggering updates.

Overview

#![allow(unused)]
fn main() {
// Query an element by its string ID
let handle = ctx.query("my-element");

// Check if it exists
if handle.exists() {
    // Get computed bounds
    if let Some(bounds) = handle.bounds() {
        println!("Element at ({}, {}) size {}x{}",
            bounds.x, bounds.y, bounds.width, bounds.height);
    }

    // Scroll into view
    handle.scroll_into_view();

    // Focus the element
    handle.focus();
}
}

Assigning Element IDs

To query an element, it must have a string ID assigned via .id():

#![allow(unused)]
fn main() {
div()
    .id("sidebar")
    .w(250.0)
    .h_full()
    .child(
        div()
            .id("nav-item-home")
            .child(text("Home"))
    )
    .child(
        div()
            .id("nav-item-settings")
            .child(text("Settings"))
    )
}

IDs should be unique within your UI. Duplicate IDs will cause the last element to win.


ElementHandle API

Creation & Existence

#![allow(unused)]
fn main() {
// Get a handle - works even if element doesn't exist yet
let handle = ctx.query("my-element");

// Check if element exists in the tree
if handle.exists() {
    // Element is rendered
}

// Get the string ID
let id = handle.id();  // "my-element"
}

Bounds & Visibility

#![allow(unused)]
fn main() {
// Get computed bounds after layout
if let Some(bounds) = handle.bounds() {
    println!("Position: ({}, {})", bounds.x, bounds.y);
    println!("Size: {}x{}", bounds.width, bounds.height);
}

// Check if visible in viewport
if handle.is_visible() {
    // Element intersects with window viewport
}
}
#![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))
        }))
}
}
#![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

  1. Assign meaningful IDs - Use descriptive IDs like "sidebar", "submit-button", "user-avatar" rather than generic names.

  2. Prefer declarative state - Use signals and reactive state for most UI updates. Use ElementHandle for imperative operations like scroll-to and focus.

  3. Use visual-only updates - When only colors/opacity/shadows change, use mark_visual_dirty() to skip layout.

  4. Handle missing elements - Always check exists() or handle None from bounds() when the element might not be rendered.

  5. Avoid ID collisions - Each ID should be unique. Consider namespacing like "dialog-submit", "sidebar-nav-home".

  6. Use on_ready for measurements - Don’t assume bounds are available immediately. Use on_ready for 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();
}
#![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

  1. Use appropriate overlay type - Modal for blocking actions, toast for notifications, dialog for forms.

  2. Provide escape routes - Always include a way to close (cancel button, backdrop click).

  3. Keep toasts brief - Short messages that don’t require action.

  4. Position context menus near cursor - Use event coordinates for natural placement.

  5. 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

  1. Keep states minimal - Only include states you need to distinguish visually.

  2. Handle all paths - Consider every possible event in each state.

  3. Use descriptive names - State names should clearly indicate the UI appearance.

  4. Return None for no-ops - If an event doesn’t cause a transition, return None.

  5. Test transitions - Verify all state paths work as expected.

  6. Use .deps() for external dependencies - When combining with signals.

  7. Use ctx.dep() over closures - Cleaner access to dependency values.

  8. Implement Default - Mark the default state with #[default] attribute.

  9. Use scoped signals - ctx.use_signal() for state local to the stateful.

  10. 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

  1. Set pointer-space on an element in CSS to enable tracking.
  2. Each frame, the system computes the pointer’s normalized position relative to that element.
  3. Results are exposed as env() variables usable in any calc() expression.
  4. 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.

ValueDescription
selfPosition relative to the element’s own bounds (default)
parentPosition relative to the parent element
viewportPosition relative to the viewport
#card { pointer-space: self; }

pointer-origin

The origin point for coordinate normalization.

ValueDescription
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:

VariableTypeDescription
env(pointer-x)floatNormalized X position in configured range
env(pointer-y)floatNormalized Y position in configured range
env(pointer-vx)floatX velocity (normalized units/second)
env(pointer-vy)floatY velocity (normalized units/second)
env(pointer-speed)floatTotal speed: sqrt(vx² + vy²)
env(pointer-distance)floatDistance from origin (normalized units)
env(pointer-angle)floatAngle from origin (radians, 0 = right, pi/2 = up)
env(pointer-inside)0.0/1.01.0 if cursor is inside element, 0.0 otherwise (smoothed)
env(pointer-active)0.0/1.01.0 if mouse button is pressed while over element
env(pointer-pressure)floatTouch/click pressure (0.0-1.0). Mouse: binary 0/1. Touch: hardware pressure (smoothed)
env(pointer-touch-count)floatNumber of active touch points (0 for mouse input)
env(pointer-hover-duration)floatSeconds 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:

FunctionSignatureDescription
mixmix(a, b, t)Linear interpolation: a + (b - a) * t
smoothstepsmoothstep(edge0, edge1, x)Hermite interpolation (smooth 0-1 curve)
stepstep(edge, x)0 if x < edge, 1 otherwise
clampclamp(min, val, max)Clamp value to range
remapremap(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

  1. Registration: When the CSS parser encounters pointer-space on an element, it stores a PointerSpaceConfig on the ElementStyle. During stylesheet application, elements with this config are registered in PointerQueryState.

  2. 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.

  3. Env resolution: When a calc() expression containing env(pointer-*) is evaluated (for opacity, border-radius, rotate, etc.), it resolves against the element’s ElementPointerState.

  4. 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-smoothing for visual properties — even a small value like 0.06 eliminates jitter and creates a polished feel.
  • Gate with pointer-inside to prevent effects from firing when the cursor is far away. Multiply: env(pointer-x) * env(pointer-inside).
  • Use smoothstep for distance effects — raw pointer-distance drops off linearly, but smoothstep creates 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-space set 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:

  1. Virtualization - Only render visible items
  2. Stable keys - Use consistent identifiers for list items
  3. 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:

  1. Minimize state reads - Read animated values once, not per-shape
  2. Use transforms - Push/pop transforms instead of recalculating positions
  3. 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

  1. Use appropriate spring stiffness - Stiffer springs settle faster
  2. Limit simultaneous animations - Too many can cause jank
  3. 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

  1. Clone Arc, not data - Use Arc::clone() for shared state
  2. Drop unused state - Clean up keyed state when no longer needed
  3. 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

DoDon’t
Use Stateful for hover/pressUse signals for visual-only changes
Batch signal updatesUpdate signals one at a time
Use Arc::clone()Clone large data into closures
Use timelines for loopsCreate many spring values
Read animated values onceRead repeatedly in draw loops

Flow Shaders

Flow shaders are a DAG-based (directed acyclic graph) real-time shader compute system that compiles to WGSL. They support fragment, compute, vertex, and material targets — powering 2D effects, GPU simulation, and 3D mesh rendering from a single declarative language. Flows can be defined in CSS stylesheets or directly in Rust using the flow! macro.

Quick Start

The fastest way to add a flow shader to an element:

#![allow(unused)]
fn main() {
use blinc_layout::flow;

let ripple = flow!(ripple, fragment, {
    input uv: builtin(uv);
    input time: builtin(time);
    node d = distance(uv, vec2(0.5, 0.5));
    node wave = sin(d * 20.0 - time * 4.0) * 0.5 + 0.5;
    output color = vec4(wave, wave, wave, 1.0);
});

div().flow(ripple).w(400.0).h(400.0)
}

The flow! macro produces a FlowGraph using Rust identifiers and primitives. Pass it directly to any element via .flow().

Anatomy of a Flow Shader

Every flow shader has a name, a target, and a body of declarations:

@flow <name> {
    target: fragment | compute | vertex | material;

    input <name>: builtin(<variable>);    // Input declarations
    step <name>: <step-type> { ... };     // Semantic steps (high-level)
    node <name> = <expression>;           // Raw computation nodes
    chain <name>: <step> | <step> | ...;  // Piped step chains
    use <flow-name>;                      // Compose other flows
    output <target> = <expression>;       // Output declarations
}

Declarations can appear in any order, but each node can only reference inputs and earlier nodes (the graph must be acyclic).

Targets

TargetUse CaseOutput
fragment2D visual effects on UI elementscolor (vec4)
computeGPU simulation, data processingNamed buffer writes
vertex3D mesh vertex transformationposition (vec4 clip-space)
material3D mesh surface/PBR shadingalbedo, metallic, roughness, etc.

Builtin Variables

Fragment / Compute Builtins

VariableTypeDescription
uvvec2Normalized element coordinates (0,0 = top-left, 1,1 = bottom-right)
timefloatElapsed time in seconds (monotonic)
resolutionvec2Element size in physical pixels
pointervec2Cursor position relative to element (0-1 range)
sdffloatSigned distance field value at the current fragment
frame_indexfloatCurrent frame number

Vertex Target Builtins

VariableTypeDescription
vertex_position / positionvec3Vertex position in model space
vertex_normal / normalvec3Vertex normal in model space
vertex_tangent / tangentvec4Tangent (xyz = dir, w = handedness)
vertex_colorvec4Per-vertex color
jointsvec4<u32>Joint indices for skeletal animation
weightsvec4Joint weights
vertex_indexfloatVertex/instance index
model_matrix / modelmat4Model-to-world transform
view_proj / view_projectionmat4View-projection matrix

Material Target Builtins

VariableTypeDescription
world_position / world_posvec3Interpolated world-space position
world_normalvec3Interpolated world-space normal
world_tangentvec3Interpolated world-space tangent
tangent_handednessfloatTangent handedness (±1)
camera_position / camera_posvec3Camera position in world space
light_direction / light_dirvec3Directional light direction
light_intensityfloatLight intensity
uvvec2Texture coordinates (also available in material)
timefloatFrame time (also available in material)

Expressions

Flow expressions support standard arithmetic, vector constructors, function calls, and swizzle access:

node a = sin(uv.x * 10.0 + time);
node b = vec4(a, a * 0.5, 1.0 - a, 1.0);
node c = mix(b, vec4(1.0, 0.0, 0.0, 1.0), 0.5);
node d = c.rgb;

Operators

OperatorExample
+, -, *, /a * 2.0 + b
Unary --a
Swizzlev.xy, v.rgb, v.x

Functions Reference

Math (scalar)

FunctionSignatureDescription
sin, cos, tanf32 -> f32Trigonometric
abs, floor, ceil, fractf32 -> f32Rounding / absolute
sqrt, exp, log, signf32 -> f32Algebraic
pow(f32, f32) -> f32Power
atan2(f32, f32) -> f32Arc tangent
mod(f32, f32) -> f32Modulus
min, max(f32, f32) -> f32Comparative
clamp(f32, f32, f32) -> f32Clamp to range
mix(f32, f32, f32) -> f32Linear interpolation
smoothstep(f32, f32, f32) -> f32Smooth Hermite
step(f32, f32) -> f32Step function

Vector

FunctionDescription
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

FunctionSignatureDescription
fbm(p, octaves)(vec2, i32) -> f32Fractal Brownian motion
fbm_ex(p, octaves, persistence)(vec2, i32, f32) -> f32FBM with custom persistence
worley(p)vec2 -> f32Worley/cellular noise
worley_grad(p)vec2 -> vec3Worley with analytic gradient (x=dist, y=gx, z=gy)
checkerboard(p, scale)(vec2, f32) -> f32Checkerboard pattern

SDF Primitives

FunctionDescription
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

FunctionDescription
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

FunctionDescription
phong(normal, light_dir, view_dir, shininess)Phong shading
blinn_phong(normal, light_dir, view_dir, shininess)Blinn-Phong shading

Matrix (for vertex/material targets)

FunctionSignatureDescription
mat4_mul_vec4(m, v)(mat4, vec4) -> vec4Matrix-vector multiply
mat4_mul(a, b)(mat4, mat4) -> mat4Matrix-matrix multiply
mat4_inverse(m) / inverse(m)mat4 -> mat4Matrix inverse
mat4_transpose(m) / transpose(m)mat4 -> mat4Matrix transpose
transform_normal(model, n)(mat4, vec3) -> vec3Transform normal by model matrix (3x3 extract)
translation_matrix(v)vec3 -> mat4Translation matrix from offset
rotation_matrix(axis, angle)(vec3, float) -> mat4Rotation from axis + angle
scale_matrix(v)vec3 -> mat4Scale matrix from factors
perspective(fov, aspect, near, far)(f, f, f, f) -> mat4Perspective projection
look_at(eye, target, up)(vec3, vec3, vec3) -> mat4View matrix
sample_texture(id, uv)(float, vec2) -> vec4Sample a bound texture at UV

Scene

FunctionDescription
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 TypeKey ParametersDescription
pattern_noisescale, detail, animationFBM noise pattern
pattern_worleyscale, threshold, edge, mask, gradientWorley cellular pattern with analytic gradient
pattern_ripplecenter, density, speedConcentric ripple rings
pattern_wavesdirection, frequency, speedDirectional sine waves
pattern_gridscale, line_widthGrid lines
pattern_gradientdirection, start, endLinear gradient (output: vec4)
pattern_plasmascale, speedPlasma texture (output: vec4)

Effect Steps

Post-processing effects that modify appearance.

Step TypeKey ParametersDescription
effect_refractsource, strengthLens refraction via Worley gradient
effect_frostsource, strength, detailFrosted glass UV jitter
effect_specularsource, intensity, powerSpecular highlight scattering
effect_fogdensity, sourceFog/haze composite
effect_lightsource, direction, intensity, powerDirectional highlights from normals

Transform Steps

Spatial coordinate transformations. Output type: vec2 (UV coordinate) or float.

Step TypeKey ParametersDescription
transform_wetaspect, scroll_speed, offsetAspect-corrected gravity scroll (for rain/drip effects)
transform_warpsource, amountWarp UV by a noise field
transform_rotateangleRotate UV coordinates
transform_scalefactorScale UV coordinates
transform_tilecountTile/repeat UV
transform_mirroraxisMirror UV
transform_polarcenterCartesian to polar coordinates

Color Steps

Map scalar values to colors. Output type: vec4.

Step TypeKey ParametersDescription
color_rampsource, stops, opacityMap scalar to color gradient
color_shiftsource, hueHue shift
color_tintsource, colorColor tinting
color_invertsourceColor inversion

Composition Steps

Combine two sources. Output type: vec4.

Step TypeKey ParametersDescription
compose_blenda, b, modeBlend two layers (screen, multiply, overlay, etc.)
compose_masksource, maskAlpha mask one input by another
compose_layerbase, overlay, opacityStack with opacity

Adjust Steps

Value curve shaping. Output type: float.

Step TypeKey ParametersDescription
adjust_falloffradius, centerDistance-based fade
adjust_remapsource, in_min, in_max, out_min, out_maxRemap value range
adjust_thresholdsource, valueHard threshold
adjust_easesource, curveApply easing curve
adjust_clampsource, min, maxClamp 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:

Define the shader in Rust and pass it directly to the element:

#![allow(unused)]
fn main() {
use blinc_layout::flow;

let shader = flow!(my_effect, fragment, {
    input uv: builtin(uv);
    input time: builtin(time);
    node wave = sin(uv.x * 10.0 + time) * 0.5 + 0.5;
    output color = vec4(wave, 0.2, 0.5, 1.0);
});

div().flow(shader).w(300.0).h(300.0)
}

The FlowGraph carries its own name and is auto-persisted by the GPU pipeline cache.

2. CSS Stylesheet

Define flows in CSS and reference them by name:

#![allow(unused)]
fn main() {
ctx.add_css(r#"
    @flow terrain {
        target: fragment;
        input uv: builtin(uv);
        step noise: pattern-noise { scale: 4.0; detail: 6; };
        output color = vec4(noise, noise, noise, 1.0);
    }

    #my-element {
        flow: terrain;
        border-radius: 16px;
    }
"#);

div().id("my-element").w(300.0).h(300.0)
}

3. Style Macros

Reference CSS-defined flows from css! or style! macros:

#![allow(unused)]
fn main() {
let style = css! {
    flow: "terrain";
    border-radius: 16px;
};

// Or with style! macro:
let style = style! {
    flow: "terrain",
    corner_radius: 16.0,
};
}

4. Name Reference

Reference a previously-defined flow by name string:

#![allow(unused)]
fn main() {
div().flow("terrain").w(300.0).h(300.0)
}

Complete Example

Here’s the wet glass demo that creates a realistic rain-on-glass effect using semantic steps:

#![allow(unused)]
fn main() {
use blinc_layout::flow;

let wetglass = flow!(wetglass, fragment, {
    input uv: builtin(uv);
    input time: builtin(time);
    input resolution: builtin(resolution);

    // Gravity gradient: more moisture at bottom
    node grav = smoothstep(0.0, 1.0, uv.y);

    // Background mist
    step mist: pattern_noise { scale: 3.0; detail: 5; animation: time * 0.02; };
    node moist = mist * (0.35 + grav * 0.65);

    // Multi-scale water drops with aspect correction and gravity scroll
    step uv1: transform_wet { aspect: resolution; scroll_speed: 0.001; };
    step uv2: transform_wet { aspect: resolution; scroll_speed: 0.0015; offset: vec2(0.38, 0.21); };
    step uv3: transform_wet { aspect: resolution; scroll_speed: 0.002; offset: vec2(0.17, 0.63); };

    // Worley drops at different scales
    step drops1: pattern_worley { uv: uv1; scale: 7.0; threshold: 0.22; edge: 0.05; mask: step(0.3, moist); gradient: true; };
    step drops2: pattern_worley { uv: uv2; scale: 12.0; threshold: 0.18; edge: 0.04; mask: step(0.2, moist); gradient: true; };
    step drops3: pattern_worley { uv: uv3; scale: 20.0; threshold: 0.13; edge: 0.03; mask: step(0.12, moist); gradient: true; };

    // Combine drops
    node drops_raw = clamp(drops1 + drops2 * 0.6 + drops3 * 0.3, 0.0, 1.0);
    node drops = smoothstep(0.05, 0.4, drops_raw);

    // Specular highlights
    step highlight: effect_specular {
        sources: drops1 drops2 drops3;
        weights: 1.0 0.6 0.3;
        direction: vec2(0.7071068, 0.7071067);
        intensity: 0.25;
        power: 64.0;
    };

    // Fog and lens distortion
    node fog = (1.0 - drops) * (0.12 + mist * 0.05);
    step lens: effect_refract { source: drops; strength: 0.025; };

    // Sample background scene through distorted UVs
    node scene = sample_scene(uv + lens);

    // Composite
    node out_r = scene.x * (1.0 - fog) + fog + highlight;
    node out_g = scene.y * (1.0 - fog) + fog + highlight;
    node out_b = scene.z * (1.0 - fog) + fog + highlight;
    output color = vec4(out_r, out_g, out_b, 0.97);
});

div().flow(wetglass).w(800.0).h(600.0)
}

Output Targets

Each flow target has specific output variables:

Fragment Outputs

OutputTypeDescription
colorvec4Fragment color (required)
alphafloatOverride alpha channel
displacementfloatSDF displacement

Compute Outputs

OutputTypeDescription
<buffer>[idx]variesWrite to named storage buffer

Vertex Outputs

OutputTypeDescription
positionvec4Clip-space position (required)
world_normalvec3World-space normal to pass to material
world_positionvec3World-space position to pass to material

Material Outputs

OutputTypeDescription
albedo / base_colorvec4Base color RGBA (required)
metallicfloatMetallic factor (0–1)
roughnessfloatRoughness factor (0–1)
emissivevec3Emissive color
surface_normalvec3Overridden surface normal
alpha_outfloatAlpha override

3D Flow Shaders

Flow shaders can drive 3D mesh rendering through vertex and material targets. These compile to vertex and fragment shaders that receive mesh geometry data and produce PBR-lit output.

Vertex Shader Flow

Transform vertex positions, apply skeletal animation, or create procedural geometry:

#![allow(unused)]
fn main() {
let vertex_flow = flow!(custom_vertex, vertex, {
    input pos: builtin(vertex_position);
    input normal: builtin(vertex_normal);
    input model: builtin(model_matrix);
    input vp: builtin(view_proj);
    input time: builtin(time);

    // Wave deformation
    node wave = sin(pos.x * 4.0 + time * 2.0) * 0.1;
    node deformed = vec3(pos.x, pos.y + wave, pos.z);

    // Standard MVP transform
    node world = mat4_mul_vec4(model, vec4(deformed.x, deformed.y, deformed.z, 1.0));
    node clip = mat4_mul_vec4(vp, world);
    node w_normal = transform_normal(model, normal);

    output position = clip;
    output world_normal = w_normal;
    output world_position = world.xyz;
});
}

Material Shader Flow

Define surface properties using the DAG — the PBR evaluation is done automatically:

#![allow(unused)]
fn main() {
let material_flow = flow!(pbr_material, material, {
    input uv: builtin(uv);
    input world_pos: builtin(world_position);
    input normal: builtin(world_normal);
    input time: builtin(time);

    // Procedural texture
    node noise = fbm(uv * 8.0, 4);
    node base = vec4(0.8 * noise, 0.3, 0.1, 1.0);

    // Metallic varies with noise
    node metal = smoothstep(0.4, 0.6, noise);

    output albedo = base;
    output metallic = metal;
    output roughness = 0.3;
    output emissive = vec3(0.0, 0.0, 0.0);
});
}

CSS-Defined 3D Flows

3D flows work identically in CSS stylesheets:

@flow terrain_vertex {
    target: vertex;
    input pos: builtin(vertex_position);
    input normal: builtin(vertex_normal);
    input model: builtin(model_matrix);
    input vp: builtin(view_proj);

    node world = mat4_mul_vec4(model, vec4(pos.x, pos.y, pos.z, 1.0));

    output position = mat4_mul_vec4(vp, world);
    output world_normal = transform_normal(model, normal);
    output world_position = world.xyz;
}

@flow terrain_material {
    target: material;
    input uv: builtin(uv);
    input normal: builtin(world_normal);

    node height = fbm(uv * 10.0, 6);
    node grass = vec4(0.2, 0.6, 0.1, 1.0);
    node rock = vec4(0.5, 0.45, 0.4, 1.0);
    node surface = mix(rock, grass, smoothstep(0.3, 0.6, height));

    output albedo = surface;
    output roughness = mix(0.8, 0.4, height);
}

Compute → 3D Pipeline

Use compute flows to simulate particle systems, physics, or procedural geometry, then feed the storage buffer data into MeshData:

#![allow(unused)]
fn main() {
// Compute flow updates particle positions
let sim = flow!(particle_sim, compute, {
    input time: builtin(time);
    buffer positions: vec4 [read_write];
    node p = positions[idx];
    node new_y = p.y + sin(time + f32(idx) * 0.1) * 0.01;
    output positions[idx] = vec4(p.x, new_y, p.z, 1.0);
});
}

The compute output can be read back and used to construct MeshData vertices, or joint matrices for skeletal animation.

Performance Tips

  • Analytic gradients: pattern_worley with gradient: true uses worley_grad() which computes distance + gradient in a single 3x3 grid pass (5x faster than finite-difference).
  • Pipeline caching: Compiled WGSL pipelines are cached by flow name in FlowPipelineCache. Reusing the same flow name across frames is free after first compile.
  • Scene copy: sample_scene() triggers a single texture copy per frame (not per element). Multiple elements sharing a scene-sampling flow share the same copy.
  • Step expansion: Semantic steps expand to optimized node graphs at parse time, not at render time. There’s zero per-frame overhead from using steps vs raw nodes.

3D Rendering

Blinc provides a GPU-accelerated 3D mesh rendering pipeline alongside its 2D UI. You can render PBR-lit meshes with shadow mapping, normal maps, skeletal animation, and custom shader passes — all within the same frame as your UI elements.

Mesh Data

The interchange format for 3D geometry is MeshData. Users convert from any source format (glTF, OBJ, FBX, procedural) into this struct.

#![allow(unused)]
fn main() {
use blinc_core::{MeshData, Vertex, Material, Mat4};
use std::sync::Arc;

let mesh = MeshData {
    vertices: Arc::new(vec![
        Vertex::new([-0.5, -0.5, 0.0])
            .with_normal([0.0, 0.0, 1.0])
            .with_uv([0.0, 0.0])
            .with_color([1.0, 0.0, 0.0, 1.0]),
        Vertex::new([0.5, -0.5, 0.0])
            .with_normal([0.0, 0.0, 1.0])
            .with_uv([1.0, 0.0])
            .with_color([0.0, 1.0, 0.0, 1.0]),
        Vertex::new([0.0, 0.5, 0.0])
            .with_normal([0.0, 0.0, 1.0])
            .with_uv([0.5, 1.0])
            .with_color([0.0, 0.0, 1.0, 1.0]),
    ]),
    indices: Arc::new(vec![0, 1, 2]),
    material: Material::default(),
    skin: None,
    morph_targets: Arc::new(Vec::new()),
    morph_weights: Vec::new(),
};
}

Vertex Format

Each vertex contains:

FieldTypeDescription
position[f32; 3]XYZ world position
normal[f32; 3]Surface normal (for lighting)
uv[f32; 2]Texture coordinates
color[f32; 4]Per-vertex RGBA color
tangent[f32; 4]Tangent vector for normal mapping (xyz + handedness)
joints[u32; 4]Bone indices for skeletal animation
weights[f32; 4]Bone weights (should sum to 1.0)

Builder methods chain naturally:

#![allow(unused)]
fn main() {
Vertex::new([0.0, 1.0, 0.0])
    .with_normal([0.0, 1.0, 0.0])
    .with_uv([0.5, 0.5])
    .with_tangent([1.0, 0.0, 0.0, 1.0])
    .with_joints([0, 1, 0, 0], [0.7, 0.3, 0.0, 0.0])
}

Materials

The Material struct controls PBR shading:

#![allow(unused)]
fn main() {
use blinc_core::{Material, TextureData, AlphaMode};

let material = Material {
    base_color: [0.8, 0.2, 0.1, 1.0],  // Red-ish
    metallic: 0.0,                        // Dielectric
    roughness: 0.5,                       // Medium roughness
    emissive: [0.0, 0.0, 0.0],          // No emission
    base_color_texture: None,             // Or Some(TextureData { rgba, width, height })
    normal_map: None,                     // Tangent-space normal map
    normal_scale: 1.0,                    // Normal map strength
    displacement_map: None,               // Height map for parallax
    displacement_scale: 0.05,             // Displacement depth
    unlit: false,                         // true = skip lighting
    alpha_mode: AlphaMode::Opaque,
    receives_shadows: true,
    casts_shadows: true,
};
}

Textures

Provide texture data as raw RGBA pixels:

#![allow(unused)]
fn main() {
let texture = TextureData {
    rgba: my_image_bytes,  // Vec<u8>, 4 bytes per pixel
    width: 512,
    height: 512,
};

let material = Material {
    base_color_texture: Some(texture),
    ..Material::default()
};
}

Normal Mapping

Normal maps add surface detail without extra geometry. The shader uses the vertex tangent and bitangent to transform tangent-space normals to world space.

#![allow(unused)]
fn main() {
let material = Material {
    normal_map: Some(TextureData {
        rgba: normal_map_pixels,
        width: 1024,
        height: 1024,
    }),
    normal_scale: 1.5,  // Exaggerate the effect
    ..Material::default()
};
}

Parallax Displacement

Height maps create the illusion of depth through parallax occlusion mapping (16-layer raymarching in the fragment shader):

#![allow(unused)]
fn main() {
let material = Material {
    displacement_map: Some(TextureData {
        rgba: height_map_pixels,  // Grayscale encoded as RGBA
        width: 512,
        height: 512,
    }),
    displacement_scale: 0.1,  // World-space depth
    ..Material::default()
};
}

Alpha Modes and Transparency

Every material declares one of three AlphaModes:

ModeDepth writeBlendUse for
Opaqueyesreplacesolid surfaces (default)
Maskyesalpha-test (discard below alpha_cutoff)hard cutouts — foliage, hair strands, decals with binary alpha
Blendnoweighted blended OIT (see below)genuine translucency — glass, smoke, soft edges

OIT, not back-to-front sort. AlphaMode::Blend routes through Weighted Blended OIT (McGuire & Bavoil 2013). Every BLEND fragment writes into an accumulation texture and a transmission texture, and a composite pass divides-and-blends the result over the opaque HDR buffer at end of frame. Callers don’t need to sort meshes back-to-front — the renderer handles overlapping BLEND layers statistically.

Submission order doesn’t matter at the API boundary. dispatch_pending_meshes stable-sorts OPAQUE + MASK before BLEND before handing to the renderer, so you can call draw_mesh_data in scene-graph order. (This matters because WBOIT requires every opaque depth to be written before any BLEND fragment runs its depth test — otherwise BLEND pixels that should be occluded by a later-dispatched opaque mesh leak into the composite. The framework sort is what lets you ignore this invariant.)

glTF loader auto-demotes misflagged BLEND. Many DCC exporters flag every material as BLEND by default. blinc_gltf::parse_material analyses each base-color texture’s alpha histogram on load:

Texture profileDemoted toReason
≥95% texels at α ≥ 0.95Opaquedense coverage, no meaningful translucency
≥99% texels at α ≤ 0.05 or α ≥ 0.95, <1% midrangeMaskstrict binary cutout
anything elsestays Blendgenuine partial alpha

Decisions log at info level — run any demo with RUST_LOG=blinc_gltf=info to see per-material authored=Blend resolved=Opaque lines. A material whose BLEND looks wrong usually means either the heuristic matched poorly (report it) or the asset really is authored that way.

OIT’s limitation. WBOIT approximates a weighted average of overlapping BLEND fragments — it can’t perfectly resolve stacked translucent layers at the same depth. In practice this is invisible for single-layer BLEND (most assets) and for sparse translucent overlays (eyelashes, tearlines, decals). Dense BLEND stacks (e.g. foliage with many overlapping leaves) may look slightly washed compared to correct back-to-front sorting; moving such assets to Mask when the alpha is binary fixes it.

Drawing in Canvas

Use draw_mesh_data on the DrawContext inside a canvas element:

#![allow(unused)]
fn main() {
canvas(|ctx: &mut dyn DrawContext, bounds| {
    ctx.draw_mesh_data(&mesh, Mat4::IDENTITY);
})
.w(800.0)
.h(600.0)
}

The Mat4 transform positions the mesh in the scene. The renderer handles vertex/index buffer upload and PBR shading automatically.

Shadow Mapping

The mesh pipeline includes a shadow depth pass. When rendering via GpuRenderer::render_mesh_data(), pass a light_view_proj matrix to enable shadows:

#![allow(unused)]
fn main() {
// Orthographic light projection for directional shadows
let light_view_proj: [f32; 16] = compute_light_matrix(light_dir, scene_bounds);

renderer.render_mesh_data(
    &target_view,
    &mesh,
    &model_matrix,
    &view_proj,
    camera_pos,
    light_dir,
    1.0,                          // light intensity
    Some(&light_view_proj),       // enables shadow pass
);
}

The shadow system uses:

  • 2048x2048 depth texture (Depth32Float)
  • Front-face culling in shadow pass (reduces shadow acne)
  • Depth bias (constant=2, slope_scale=2.0) for further acne reduction
  • 4-tap PCF sampling for soft shadow edges

Materials control shadow behavior per-mesh:

#![allow(unused)]
fn main() {
let floor = Material {
    receives_shadows: true,   // Shadows appear on this surface
    casts_shadows: false,     // This mesh doesn't cast shadows
    ..Material::default()
};
}

Skeletal Animation

Animate meshes with bone transforms. The GPU applies per-vertex skinning using up to 4 joint influences.

Skeleton Definition

#![allow(unused)]
fn main() {
use blinc_core::{Bone, Skeleton, SkinningData};

let skeleton = Skeleton {
    bones: vec![
        Bone {
            name: "Root".into(),
            parent: None,
            inverse_bind_matrix: identity_matrix(),
        },
        Bone {
            name: "UpperArm".into(),
            parent: Some(0),
            inverse_bind_matrix: upper_arm_ibm,
        },
        Bone {
            name: "LowerArm".into(),
            parent: Some(1),
            inverse_bind_matrix: lower_arm_ibm,
        },
    ],
};
}

Per-Frame Skinning

Each frame, compute the joint matrices and attach them to the mesh:

#![allow(unused)]
fn main() {
// joint_matrix[i] = current_world_transform[i] * inverse_bind_matrix[i]
let joint_matrices: Vec<[f32; 16]> = skeleton.bones.iter()
    .enumerate()
    .map(|(i, bone)| {
        multiply_mat4(&animated_world_transforms[i], &bone.inverse_bind_matrix)
    })
    .collect();

let mesh = MeshData {
    vertices: Arc::new(skinned_vertices),  // vertices with .joints and .weights set
    indices: Arc::new(indices),
    material: Material::default(),
    skin: Some(SkinningData { joint_matrices }),
    morph_targets: Arc::new(Vec::new()),
    morph_weights: Vec::new(),
};
}

Vertex Skinning

Vertices reference bones by index:

#![allow(unused)]
fn main() {
Vertex::new([0.0, 1.0, 0.0])
    .with_joints(
        [0, 1, 0, 0],       // bone indices
        [0.6, 0.4, 0.0, 0.0] // weights (sum to 1.0)
    )
}

The GPU vertex shader computes:

skin_matrix = joint[0] * w0 + joint[1] * w1 + joint[2] * w2 + joint[3] * w3
position = skin_matrix * vertex_position
normal = skin_matrix * vertex_normal

Maximum 256 joints per mesh, stored in a GPU storage buffer.

Custom Render Passes

Inject your own GPU render passes into the pipeline. Passes execute at specific stages — before UI rendering (PreRender) or after (PostProcess).

Basic Custom Pass

#![allow(unused)]
fn main() {
use blinc_gpu::{CustomRenderPass, RenderPassContext, RenderStage};

struct SkyboxPass {
    pipeline: Option<wgpu::RenderPipeline>,
}

impl CustomRenderPass for SkyboxPass {
    fn label(&self) -> &str { "skybox" }
    fn stage(&self) -> RenderStage { RenderStage::PreRender }

    fn initialize(&mut self, device: &wgpu::Device, _queue: &wgpu::Queue, format: wgpu::TextureFormat) {
        // Create your render pipeline, bind groups, etc.
    }

    fn render(&mut self, ctx: &RenderPassContext) {
        let mut encoder = ctx.device.create_command_encoder(&Default::default());
        {
            let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
                label: Some("Skybox"),
                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
                    view: ctx.target,
                    resolve_target: None,
                    ops: wgpu::Operations {
                        load: wgpu::LoadOp::Load,
                        store: wgpu::StoreOp::Store,
                    },
                })],
                ..Default::default()
            });
            // Draw skybox...
        }
        ctx.queue.submit(std::iter::once(encoder.finish()));
    }
}

// Register with the renderer
renderer.register_custom_pass(Box::new(SkyboxPass { pipeline: None }));
}

Render Stages

StageWhenUse Cases
PreRenderBefore UI primitivesSkyboxes, 3D scene backgrounds, grid overlays
PostProcessAfter all UI renderingBloom, tone mapping, FXAA, vignette, debug overlays

Custom Bind Groups

The BindGroupBuilder creates matched layout + bind group pairs:

#![allow(unused)]
fn main() {
use blinc_gpu::BindGroupBuilder;

let mut builder = BindGroupBuilder::new("my_effect");
builder.add_uniform_buffer(uniforms_buffer.as_entire_binding());
builder.add_texture(&my_texture_view);
builder.add_sampler(&my_sampler);
builder.add_storage_buffer(data_buffer.as_entire_binding(), true); // read-only

let (layout, bind_group) = builder.build(device);
}

Supported binding types:

MethodShader TypeNotes
add_uniform_buffer()var<uniform>Per-frame data (transforms, time, etc.)
add_storage_buffer(_, read_only)var<storage>Large data arrays, particle buffers
add_texture()texture_2d<f32>Sampled textures (filterable)
add_storage_texture()texture_storage_2dCompute write targets
add_sampler()samplerFiltering sampler
add_comparison_sampler()sampler_comparisonShadow map sampling

Compute Shaders

Execute compute shaders for simulation, particle updates, or data processing:

#![allow(unused)]
fn main() {
use blinc_gpu::{create_compute_pipeline, ComputeDispatch, BindGroupBuilder};

// Create pipeline from WGSL
let pipeline = create_compute_pipeline(
    device,
    "particle_sim",
    include_str!("shaders/particle_sim.wgsl"),
    "cs_main",
    &bind_group_layout,
);

// Dispatch
let dispatch = ComputeDispatch {
    pipeline: &pipeline,
    bind_group: &bind_group,
    workgroups: (particle_count / 64, 1, 1),
    label: "particle_sim",
};
dispatch.execute(device, queue);
}

Post-Processing Chain

Chain multiple screen-space effects with automatic ping-pong texture management:

#![allow(unused)]
fn main() {
use blinc_gpu::{PostProcessChain, PostProcessEffect};

struct BloomEffect { /* ... */ }

impl PostProcessEffect for BloomEffect {
    fn label(&self) -> &str { "bloom" }

    fn initialize(&mut self, device: &wgpu::Device, _queue: &wgpu::Queue, format: wgpu::TextureFormat) {
        // Create bloom pipeline, intermediate textures, etc.
    }

    fn apply(&mut self, device: &wgpu::Device, queue: &wgpu::Queue,
             input: &wgpu::TextureView, output: &wgpu::TextureView,
             width: u32, height: u32) {
        // Read from input, write bloom result to output
    }
}

// Build a chain
let mut chain = PostProcessChain::new("my_effects");
chain.add_effect(Box::new(BloomEffect::new()));
chain.add_effect(Box::new(ToneMappingEffect::new()));

// Register as a custom pass (runs at PostProcess stage)
renderer.register_custom_pass(Box::new(chain));
}

The chain automatically:

  • Copies the framebuffer to a ping texture
  • Chains effects: ping → pong → ping → … → framebuffer
  • Manages texture lifetimes and resizing
  • Skips disabled effects

Flow Shader Integration

The flow shader system extends beyond 2D effects — it is a general-purpose DAG compute system that compiles to WGSL for any target. For 3D rendering, use vertex and material flow targets.

Declarative Vertex Shader

Instead of writing raw WGSL, define vertex transforms as a flow DAG:

#![allow(unused)]
fn main() {
use blinc_layout::flow;

let wave_vertex = flow!(wave_vertex, vertex, {
    input pos: builtin(vertex_position);
    input normal: builtin(vertex_normal);
    input model: builtin(model_matrix);
    input vp: builtin(view_proj);
    input time: builtin(time);

    node wave = sin(pos.x * 4.0 + time * 2.0) * 0.1;
    node deformed = vec3(pos.x, pos.y + wave, pos.z);
    node world = mat4_mul_vec4(model, vec4(deformed.x, deformed.y, deformed.z, 1.0));

    output position = mat4_mul_vec4(vp, world);
    output world_normal = transform_normal(model, normal);
    output world_position = world.xyz;
});
}

Declarative Material Shader

Define PBR surface properties — the flow compiler injects Blinn-Phong + Fresnel evaluation automatically:

#![allow(unused)]
fn main() {
let terrain_mat = flow!(terrain_mat, material, {
    input uv: builtin(uv);
    input normal: builtin(world_normal);

    node height = fbm(uv * 10.0, 6);
    node grass = vec4(0.2, 0.6, 0.1, 1.0);
    node rock = vec4(0.5, 0.45, 0.4, 1.0);

    output albedo = mix(rock, grass, smoothstep(0.3, 0.6, height));
    output roughness = mix(0.8, 0.4, height);
    output metallic = 0.0;
});
}

CSS-Defined 3D Shaders

Flow shaders for 3D work in CSS stylesheets too:

@flow ocean_vertex {
    target: vertex;
    input pos: builtin(position);
    input model: builtin(model);
    input vp: builtin(view_proj);
    input time: builtin(time);

    node wave = sin(pos.x * 3.0 + time) * 0.2 + sin(pos.z * 2.0 + time * 1.3) * 0.15;
    node displaced = vec3(pos.x, pos.y + wave, pos.z);
    node world = mat4_mul_vec4(model, vec4(displaced.x, displaced.y, displaced.z, 1.0));

    output position = mat4_mul_vec4(vp, world);
    output world_position = world.xyz;
}

Tip: See the Flow Shaders chapter for the complete function reference, semantic steps, chains, and composition with use.

Raw Pixel Drawing

For video frames, camera previews, or procedural textures, use draw_rgba_pixels:

#![allow(unused)]
fn main() {
canvas(|ctx: &mut dyn DrawContext, bounds| {
    // Upload and render RGBA pixel data in one call
    ctx.draw_rgba_pixels(
        &rgba_data,     // &[u8], 4 bytes per pixel
        width,          // u32
        height,         // u32
        Rect::new(0.0, 0.0, bounds.width, bounds.height),
    );
})
.w(640.0)
.h(480.0)
}

This creates a GPU texture each frame — ideal for dynamic content like video playback or camera streams.

GPU Memory Budget

The renderer tracks GPU texture memory and enforces a configurable budget:

#![allow(unused)]
fn main() {
// Default: 128 MB, override with BLINC_GPU_MEMORY_BUDGET_MB env var
let config = RendererConfig {
    gpu_memory_budget: 256 * 1024 * 1024,  // 256 MB
    ..RendererConfig::default()
};
}

Call renderer.enforce_memory_budget() once per frame to evict cached textures when over budget. Eviction is largest-first (XLarge pool textures → mask image cache).

Architecture

The 3D rendering pipeline sits alongside the 2D SDF pipeline:

Frame
 ├── PreRender custom passes (skybox, 3D scene)
 ├── UI Rendering
 │   ├── SDF primitives (2D shapes)
 │   ├── Glass / vibrancy effects
 │   ├── Text glyphs
 │   ├── Canvas callbacks → draw_mesh_data / draw_rgba_pixels
 │   └── Layer effects (blur, shadow, glow)
 ├── PostProcess custom passes (bloom, tone mapping)
 └── Memory budget enforcement

The mesh pipeline (MeshPipeline) is lazily created on first use and includes:

  • Main PBR render pipeline with normal/displacement/shadow support
  • Shadow depth pass pipeline (Depth32Float, front-face culling)
  • Default textures (white, flat normal, black displacement)
  • Joint matrix storage buffer (for skeletal animation)
  • Comparison sampler (for PCF shadow sampling)

Tip: For static 3D scenes, render to an offscreen texture once, then display it as an image. Only re-render when the camera or scene changes.

Architecture Overview

Blinc is a high-performance UI framework built from the ground up for GPU-accelerated rendering without virtual DOM overhead. This chapter explains how the major systems work together.

Design Philosophy

Blinc follows several key principles:

  1. Fine-grained Reactivity - Signal-based state management with automatic dependency tracking eliminates the need for virtual DOM diffing
  2. Layout as Separate Concern - Tree structure is independent from visual properties, enabling visual-only updates without layout recomputation
  3. GPU-First Rendering - SDF shaders provide resolution-independent, smooth rendering with glass/blur effects
  4. Incremental Updates - Hash-based diffing with change categories minimizes recomputation
  5. 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

CratePurpose
blinc_coreReactive signals, FSM, core types, event system
blinc_layoutElement builders, Taffy integration, diff system, stateful elements
blinc_animationSpring physics, keyframe timelines, animation scheduler
blinc_gpuwgpu renderer, SDF shaders, glass effects, text rendering
blinc_textFont loading, glyph shaping, text atlas
blinc_appWindowedApp, 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:

  1. Creating VDOM objects for every render
  2. Diffing the full tree to find changes
  3. Patching the real DOM with changes

Blinc avoids this with:

  1. Fine-grained signals - Only dependent code re-runs when state changes
  2. Stateful elements - UI state managed at the element level, not rebuilt from scratch
  3. Hash-based diffing - Quick equality checks without deep comparison
  4. 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

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

TypeDescription
RectRounded rectangle with per-corner radius
CirclePerfect circle
EllipseAxis-aligned ellipse
ShadowDrop shadow with Gaussian blur
InnerShadowInset shadow
TextGlyph sampled from texture atlas

Fill Types

TypeDescription
SolidSingle color fill
LinearGradientGradient between two points
RadialGradientGradient 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

TypeBlurSaturationUse Case
UltraThinLightHighSubtle overlays
ThinMediumMediumSidebars
RegularStandardStandardModals, cards
ThickHeavyLowHeaders
ChromeVery heavyLowWindow chrome

Glass Shader

The glass shader:

  1. Samples backbuffer - Reads rendered content behind the glass
  2. Applies Gaussian blur - Multi-tap sampling with weights
  3. Adjusts saturation - Controls color vibrancy
  4. Adds tint color - Overlays glass tint
  5. Applies noise grain - Frosted texture effect
  6. 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:

  1. Renders background to backbuffer texture
  2. Renders glass elements sampling the backbuffer
  3. Renders foreground elements on top
  4. Composites all layers

Text Rendering

Text uses a separate glyph atlas system:

  1. Font Loading - Parses TTF/OTF via rustybuzz
  2. Glyph Shaping - HarfBuzz-compatible shaping for complex scripts
  3. Atlas Generation - Rasterizes glyphs to texture atlas
  4. SDF Text - Stores distance field for each glyph
  5. 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 CountQualityPerformance
1xBaselineFastest
2xImproved edgesSlight cost
4xGood qualityModerate cost
8xHigh qualityHigher 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

TypePurpose
SignalNodeStores value + list of subscribers
DerivedNodeCached computed value + dirty flag
EffectNodeSide-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:

  1. First accessed after creation
  2. Accessed after a dependency changed
  3. 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

  1. batch_start() increments batch depth counter
  2. Signal updates mark subscribers dirty but don’t run effects
  3. batch_end() decrements counter
  4. 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

  • SignalId is a 64-bit handle (Copy)
  • Subscriber lists use SmallVec<[_; 4]> (inline for small counts)
  • SlotMap provides dense storage without gaps

Comparison to Virtual DOM

AspectVirtual DOMBlinc Reactive
State changeRebuild entire componentUpdate only affected nodes
DiffingO(tree size)O(1) per signal
MemoryVDOM objects per renderFixed signal storage
Dependency trackingManual (useEffect deps)Automatic

Best Practices

  1. Use keyed state for persistence - use_state_keyed("key", || value) survives rebuilds

  2. Batch related updates - Group multiple signal changes to avoid redundant work

  3. Declare dependencies explicitly - Use .deps() for stateful elements that read signals

  4. Prefer stateful for visual changes - Use stateful elements instead of signals for hover/press effects

  5. 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 PropertiesVisual Properties
width, heightbackground
padding, marginborder_color
flex_directionshadow
justify_contentopacity
align_itemstransform
gaprounded

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
}
}
CategoryExample ChangesAction
Visual onlybg, opacity, shadowUpdate RenderProps
Layoutwidth, padding, gapRecompute layout
ChildrenAdd/remove childRebuild subtree
HandlersEvent callback changedUpdate 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

  1. Compute hashes for all old and new children
  2. Build hash map of old children by hash
  3. Match new children to old by hash lookup
  4. Detect moves when hash matches at different position
  5. Classify remaining as added or removed
  6. 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

ScenarioWhat Runs
Hover color changeUpdate 1 RenderProps
Text content changeRebuild 1 text node
Add item to listInsert node, layout affected subtree
Reorder listMove 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

PresetStiffnessDampingCharacter
stiff()40030Fast, minimal overshoot
snappy()30020Quick with slight bounce
gentle()12014Soft, slower motion
wobbly()18012Bouncy, playful
molasses()5020Very 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

EasingDescription
linearConstant speed
ease_inStart slow, end fast
ease_outStart fast, end slow
ease_in_outSlow at both ends
ease_in_quadQuadratic ease in
ease_out_cubicCubic ease out
ease_in_out_elasticElastic bounce

Animation Fill Modes

ModeDescription
NoneRevert after animation
ForwardsKeep final value
BackwardsApply initial before start
BothForwards + 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

  1. Consistent timing - Animations run at 120fps regardless of main thread
  2. Survives focus loss - Continues when window loses focus
  3. Non-blocking - Doesn’t block UI event processing
  4. 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

  1. At build time: Reads current animation values
  2. Stores binding: Remembers which animated values to sample
  3. At render time: Samples current values from scheduler
  4. 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

  1. Spring settling - Stopped springs don’t consume CPU
  2. Batched ticks - All animations tick together
  3. No allocations - Animation values are pre-allocated
  4. GPU transforms - Motion transforms are GPU-accelerated
  5. 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

  1. Builder creation: stateful::<S>() creates a StatefulBuilder for state type S
  2. Key generation: Automatic key based on call site location
  3. Event routing: Pointer/keyboard events are routed to the FSM
  4. State transition: FSM computes new state from (current_state, event)
  5. Callback invocation: on_state callback runs with StateContext
  6. 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 transitionsAlso runs when dependencies change
Hover/press onlyExternal 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 stateFSM for visual state
Triggers full rebuildUpdates only affected element
Creates new VDOMMutates existing element
O(tree size)O(1)

Minimal Updates

Stateful elements only update their own RenderProps:

#![allow(unused)]
fn main() {
// State change only affects this element
.on_state(|ctx| {
    div().bg(new_color)  // Updates RenderProps
    // No layout recomputation
    // No tree diff
    // Just visual update
})
}

Queued Updates

State changes queue updates efficiently:

#![allow(unused)]
fn main() {
static PENDING_PROP_UPDATES: Vec<(NodeId, RenderProps)>;

// Stateful callback queues update
fn on_state(ctx) -> Div {
    div().bg(color)
    // Queues: (node_id, updated_props)
}

// Processed in batch by windowed app
for (node_id, props) in drain_pending() {
    render_tree.update_props(node_id, props);
}
}

Writing a Cross-Target Example

Every example under examples/blinc_app_examples/examples/ runs on every platform Blinc supports — desktop via WindowedApp::run, web via WebApp::run_with_setup, and (where the widgets allow) iOS and Android via the mobile runners — with no per-target forks. A single source file is the source of truth for all targets.

The Example Gallery is assembled from this same set, auto-discovered by tools/build-web-examples and published to GitHub Pages via CI. Adding a new example requires writing one file that follows the convention below. Nothing else. No manifest entry. No wrapper crate. No CI change.

The convention

Every cross-target example must define exactly one function with this signature, as a top-level pub fn:

#![allow(unused)]
fn main() {
pub fn build_ui(ctx: &mut WindowedContext) -> impl ElementBuilder {
    // The actual demo UI.
}
}

And its fn main must be cfg-gated to non-wasm targets:

#[cfg(not(target_arch = "wasm32"))]
fn main() -> blinc_app::Result<()> {
    tracing_subscriber::fmt()
        .with_max_level(tracing::Level::INFO)
        .init();

    let config = WindowConfig {
        title: "My Example".to_string(),
        width: 800,
        height: 600,
        ..Default::default()
    };

    blinc_app::windowed::WindowedApp::run(config, build_ui)
}

That’s it. Run the codegen tool:

cargo run -p blinc-build-web-examples

Your example is now auto-discovered, wrapped as a wasm32 crate under examples/_generated/<name>/, built by CI, and appears in the Example Gallery with the title and description pulled from your //! doc comment.

The full template

A complete minimal example looks like this:

//! My New Example
//!
//! One-paragraph description of what the demo shows. This text
//! becomes the gallery page description verbatim — keep it short.
//! Bullet points render fine:
//! - First thing the example demonstrates
//! - Second thing
//!
//! Run with: cargo run -p blinc_app_examples --example my_new --features windowed

use blinc_app::prelude::*;
use blinc_app::windowed::WindowedContext;

#[cfg(not(target_arch = "wasm32"))]
fn main() -> Result<()> {
    tracing_subscriber::fmt()
        .with_max_level(tracing::Level::INFO)
        .init();

    let config = WindowConfig {
        title: "My New Example".to_string(),
        width: 800,
        height: 600,
        ..Default::default()
    };

    blinc_app::windowed::WindowedApp::run(config, build_ui)
}

pub fn build_ui(ctx: &mut WindowedContext) -> impl ElementBuilder {
    div()
        .w(ctx.width)
        .h(ctx.height)
        .bg(Color::rgba(0.08, 0.08, 0.12, 1.0))
        .items_center()
        .justify_center()
        .child(
            text("Hello, Blinc!")
                .size(32.0)
                .color(Color::WHITE),
        )
}

Save that as examples/blinc_app_examples/examples/my_new.rs and run cargo run -p blinc-build-web-examples. The gallery picks it up on the next book build.

What the codegen tool extracts from your file

  • Title — the first non-empty line of the //! doc block. The “ Example“ / “ Demo“ suffix is stripped for display, so //! Scroll Container Example becomes Scroll Container.
  • Description — everything from the second //! paragraph up to (but not including) the first Run with: line. Rendered verbatim as markdown on the gallery page.
  • Dependencies — the tool greps your source for blinc_cn:: / blinc_icons:: / blinc_tabler_icons:: / blinc_canvas_kit:: / blinc_theme:: / etc. and adds matching path = "..." dependencies to the generated wrapper’s Cargo.toml. If you use a workspace crate the tool doesn’t know about yet, add it to the INFERABLE_DEPS table in tools/build-web-examples/src/main.rs.

Constraints

The return type must be impl ElementBuilder, not Div

impl ElementBuilder lets you return anything Blinc considers a valid root element: Div, Scroll, Stateful<T>, Canvas, MotionContainer, etc. Returning Div specifically would force you to wrap non-Div roots like scroll().child(...) in an extra div().child(...) just to satisfy the type system, which adds a pointless layout node.

The web runner (WebApp::run_with_setup) accepts any ElementBuilder via the internal UiBuilderFn trait — see crates/blinc_app/src/web.rs for the type-erasure machinery. You should never need to think about it; just return whatever feels natural.

ctx must be &mut WindowedContext, not &WindowedContext

The web runner’s frame loop holds a mutable borrow of the context for reactive state bookkeeping. Taking &mut makes your build_ui compatible with both WindowedApp::run (desktop) and WebApp::run_with_setup (web); taking & only works on desktop.

fn main must be #[cfg(not(target_arch = "wasm32"))]-gated

Without the cfg gate, cargo check --target wasm32-unknown-unknown would compile your WindowedApp::run call into a wasm binary, and that method isn’t available on the web target. The gate also means the auto-generated wrapper crate can include! your example source without colliding with its own #[wasm_bindgen(start)] entry point.

State initialization goes inside build_ui, not before it

Historically a lot of the framework’s examples initialized an Arc<Mutex<...>> or a timeline in fn main and captured it into the closure passed to WindowedApp::run. That pattern doesn’t translate to the web target, because the wasm wrapper only has access to build_ui — it never sees whatever state fn main set up. Put the state setup inside build_ui and use ctx.use_state_keyed / ctx.use_animated_timeline to persist it across rebuilds:

#![allow(unused)]
fn main() {
pub fn build_ui(ctx: &mut WindowedContext) -> impl ElementBuilder {
    // Persistent state keyed by string — survives rebuilds.
    let count = ctx.use_state_keyed("counter", || 0i32);

    // Persistent animation timeline — also survives rebuilds.
    let timeline = ctx.use_animated_timeline();

    div().child(/* ... use count and timeline ... */)
}
}

Opting out

Some examples can’t run on the web target — multi-window demos, CLI diagnostics, OS-specific runners. For these, add a //! no-web: line with a short reason to the top of the doc block:

#![allow(unused)]
fn main() {
//! Multi-Window Demo
//!
//! no-web: the web target has no multi-window concept — a browser
//! tab is a single `<canvas>`. `open_window_with()` doesn't
//! translate to the browser. Kept desktop-only on purpose.
//!
//! Demonstrates: ...
}

The codegen tool skips any file with no-web: in its doc block (no wrapper crate, no gallery entry) without erroring out. The desktop build is untouched, and the example continues to work as cargo run -p blinc_app_examples --example <name> --features windowed.

Currently opted out:

  • css_parser_demo — CLI diagnostic, no event loop
  • fuchsia_hello — Fuchsia OS target
  • multi_window_demo — multi-window not supported on web

Running locally

Desktop:

cargo run -p blinc_app_examples --example my_new --features windowed

Unchanged from before the cross-target convention.

Web:

# 1. Generate (or regenerate) the wasm wrapper crate
cargo run -p blinc-build-web-examples

# 2. Build it with wasm-pack
cd examples/_generated/my_new
wasm-pack build --target web --release

# 3. Serve it
./serve.sh 8000
# Open http://localhost:8000 in Chrome 113+

For iterating on a single example, once the wrapper exists you can skip step 1 on subsequent runs — cargo’s rerun-if-changed in the wrapper’s build.rs catches edits to your upstream example automatically. Only add / remove / rename operations require a fresh codegen pass.

What the tool generates

Running cargo run -p blinc-build-web-examples (no flags) writes:

  • examples/_generated/<name>/ — one wrapper crate per discovered example. Contents: Cargo.toml, build.rs, src/lib.rs, index.html, serve.sh, .gitignore.
  • docs/book/src/web/example-gallery.md — the gallery index.
  • docs/book/src/web/example-gallery/<name>.md — one page per example with an iframe of the wasm build.
  • docs/book/src/SUMMARY.md — patched between <!-- begin:web-examples --> / <!-- end:web-examples --> markers to include the new gallery pages in the book’s TOC.

Everything under examples/_generated/ is gitignored (except .gitignore + .gitkeep markers) so the generated tree is rebuilt on every CI run and never ends up in a commit.

Flags:

  • --build — after codegen, run wasm-pack build --target web --release in each wrapper. Used by CI.
  • --stage-to <dir> — after --build, copy each wrapper’s index.html + pkg/ into <dir>/<name>/. Used by CI to drop artifacts into target/book/examples/ for mdBook iframe resolution. Implies --build.
  • --no-gallery — skip the markdown + SUMMARY patch. Useful for lint-only CI steps that don’t need to touch the book source.

Why this design

The earlier version of the repo had hand-written wrapper crates for every web example (examples/web_hello, web_drag, web_assets, web_mobile_demo). That worked for a handful but didn’t scale: every new example meant a new directory, new Cargo.toml, new index.html, new serve.sh, plus duplicated code between the desktop and web entry points.

The convention-driven approach collapses all that to one .rs file that compiles for both targets. The wrapper crate generation is purely mechanical: the codegen tool parses your example with syn, checks for the convention, and emits the wrapper from a template. There’s no magic, no AST rewriting — just file I/O.