Keyboard shortcuts

Press ← or → to navigate between chapters

Press ? to show this help

Press Esc to hide this help

Components

Rinch provides a comprehensive component library with 60+ styled, themeable UI components inspired by Mantine. Enable with the components feature (which also enables theme):

[dependencies]
rinch = { workspace = true, features = ["desktop", "components", "theme"] }

Note: The workspace dependency uses default-features = false, so features must be listed explicitly:

  • "desktop" — enables run(), window management, and the software renderer (tiny-skia)
  • "gpu" — (optional) adds GPU rendering via Vello/wgpu beside the software renderer, chosen at run time
  • "components" — enables the component library (Button, TextInput, Stack, etc.)
  • "theme" — enables automatic theme CSS loading and CSS variables

Using Components

Components are used directly in RSX with a declarative syntax:

use rinch::prelude::*;

#[component]
fn app() -> NodeHandle {
    rsx! {
        Stack { gap: "md",
            Title { order: 1, "Welcome" }
            Text { size: "lg", color: "dimmed", "Get started with Rinch components." }

            Group { gap: "sm",
                Button { variant: "filled", "Get Started" }
                Button { variant: "outline", "Learn More" }
            }
        }
    }
}

fn main() {
    let theme = ThemeProviderProps {
        primary_color: Some("cyan".into()),
        ..Default::default()
    };
    App::new(app)
        .title("My App")
        .size(800, 600)
        .theme(theme)
        .run();
}

Component Categories

Layout Components

ComponentDescription
StackVertical flex container with spacing
GroupHorizontal flex container with spacing
ContainerCentered max-width container
CenterCenter content horizontally/vertically
SpaceEmpty space component
#![allow(unused)]
fn main() {
// Stack - vertical layout
Stack { gap: "md",
    Text { "Item 1" }
    Text { "Item 2" }
}

// Group - horizontal layout
Group { gap: "sm", justify: "space-between",
    Button { "Left" }
    Button { "Right" }
}

// Center content
Center {
    Loader {}
}

// Add spacing
Space { h: "xl" }  // height
Space { w: "md" }  // width
}

Buttons

ComponentDescription
ButtonClickable button with variants
ActionIconIcon-only button
CloseButtonDismiss/close button
#![allow(unused)]
fn main() {
// Button variants
Button { variant: "filled", "Primary" }
Button { variant: "outline", "Secondary" }
Button { variant: "light", "Light" }
Button { variant: "subtle", "Subtle" }

// Button sizes
Button { size: "xs", "Extra Small" }
Button { size: "sm", "Small" }
Button { size: "md", "Medium" }  // default
Button { size: "lg", "Large" }
Button { size: "xl", "Extra Large" }

// Button colors
Button { color: "red", "Delete" }
Button { color: "green", "Success" }

// Button states
Button { disabled: true, "Disabled" }
Button { loading: true, "Loading..." }
Button { full_width: true, "Full Width" }

// With click handler
Button { onclick: move || count.update(|n| *n += 1), "Click Me" }

// ActionIcon - for icon buttons
ActionIcon { variant: "filled", "+" }
ActionIcon { size: "lg", variant: "light", "?" }

// CloseButton
CloseButton { onclick: move || modal_open.set(false) }
}

Form Inputs

ComponentDescription
TextInputSingle-line text input
TextareaMulti-line text input
PasswordInputPassword field with visibility toggle
NumberInputNumeric input with controls
CheckboxCheckbox input
SwitchToggle switch
SelectDropdown select
Radio / RadioGroupRadio buttons
SliderRange input slider
FieldsetGrouped form fields
#![allow(unused)]
fn main() {
// TextInput with label and description
TextInput {
    label: "Email",
    placeholder: "you@example.com",
    description: "We'll never share your email."
}

// TextInput with error
TextInput {
    label: "Password",
    input_type: "password",
    error: "Password must be at least 8 characters"
}

// Controlled input (value_fn + oninput)
let input_text = Signal::new(String::new());
TextInput {
    placeholder: "Type here...",
    value_fn: move || input_text.get(),
    oninput: move |value: String| input_text.set(value),
}

// TextInput with onsubmit (fires on Enter key)
let search = Signal::new(String::new());
TextInput {
    placeholder: "Search...",
    value_fn: move || search.get(),
    oninput: move |value: String| search.set(value),
    onsubmit: move || println!("Searching: {}", search.get()),
}

// PasswordInput with visibility toggle
PasswordInput {
    label: "Password",
    placeholder: "Enter password"
}

// NumberInput — uncontrolled: the component owns the number, so the
// +/- steppers step, clamp to [min, max] and format it themselves,
// reporting each written value through `oninput` (#501)
NumberInput {
    label: "Quantity",
    value: Some(1.0),
    min: Some(0.0),
    step: Some(1.0)
}

// NumberInput — controlled: pass `value_fn` and the signal becomes the
// field's single write path; the steppers stay callback-only, so write
// the signal in `onincrement`/`ondecrement` (#264)
let quantity = Signal::new(1.0);
NumberInput {
    label: "Quantity",
    value_fn: move || quantity.get().to_string(),
    min: Some(0.0),
    step: Some(1.0),
    onincrement: move || quantity.update(|v| *v += 1.0),
    ondecrement: move || quantity.update(|v| *v = (*v - 1.0).max(0.0))
}

// Textarea
Textarea {
    label: "Description",
    placeholder: "Enter your message...",
    min_rows: 4          // visible rows; defaults to 2
}

// Checkbox
let checked = Signal::new(false);
Checkbox {
    label: "Accept terms",
    checked: checked.get(),
    onchange: move || checked.update(|v| *v = !*v)
}

// Switch
Switch {
    label: "Enable notifications",
    checked: enabled.get(),
    onchange: move || enabled.update(|v| *v = !*v)
}

// Select
Select {
    label: "Country",
    placeholder: "Select a country",
    option { value: "us", "United States" }
    option { value: "uk", "United Kingdom" }
    option { value: "ca", "Canada" }
}

// RadioGroup
RadioGroup { label: "Plan",
    Radio { value: "free", label: "Free" }
    Radio { value: "pro", label: "Pro" }
    Radio { value: "enterprise", label: "Enterprise" }
}

// Slider
Slider { color: "cyan" }

// Fieldset
Fieldset { legend: "Personal Info",
    Stack { gap: "sm",
        TextInput { placeholder: "First name" }
        TextInput { placeholder: "Last name" }
    }
}
}

Typography

ComponentDescription
TextText display with styling
TitleHeading text (h1-h6)
CodeInline or block code
KbdKeyboard key
AnchorStyled link
BlockquoteStyled quotation
MarkHighlighted text
HighlightSearch text highlighting
// Text sizes and weights
Text { size: "lg", weight: "bold", "Large Bold Text" }
Text { size: "sm", color: "dimmed", "Small dimmed text" }

// Titles (h1-h6)
Title { order: 1, "Main Heading" }
Title { order: 2, "Subheading" }
Title { order: 3, "Section Title" }

// Code
Text { "Run " Code { "npm install" } " to install." }
Code { block: true, "fn main() {\n    println!(\"Hello!\");\n}" }

// Keyboard keys
Group { gap: "xs",
    Kbd { "Ctrl" }
    Text { "+" }
    Kbd { "C" }
}

// Anchor (link)
Anchor { href: "https://example.com", "Visit site" }
Anchor { href: "#", underline: true, "Always underlined" }

// Blockquote
Blockquote { cite: "— Albert Einstein",
    "Imagination is more important than knowledge."
}

// Mark (highlight)
Text { "This has " Mark { "marked text" } " inline." }

// Highlight (search highlighting)
Highlight { highlight: "quick brown", "The quick brown fox jumps" }

Feedback

ComponentDescription
AlertUser feedback messages
LoaderLoading spinner/indicator
ProgressProgress bar
SkeletonLoading placeholder
TooltipCSS-only hover tooltip
#![allow(unused)]
fn main() {
// Alert variants
Alert { color: "blue", title: "Info", "This is informational." }
Alert { color: "green", title: "Success", "Operation completed!" }
Alert { color: "yellow", title: "Warning", "Please review." }
Alert { color: "red", title: "Error", "Something went wrong." }

Alert { variant: "filled", color: "blue", "Filled style" }
Alert { variant: "light", color: "blue", "Light style" }
Alert { variant: "outline", color: "blue", "Outline style" }

// Loader
Loader {}
Loader { size: "lg", color: "cyan" }

// Progress
Progress { color: "blue" }
Progress { color: "green", striped: true }
Progress { striped: true, animated: true }

// Skeleton (loading placeholders)
Skeleton { height: "12px", width: "80%" }
Skeleton { height: "40px", width: "40px", circle: true }

// Tooltip
Tooltip { label: "This is a tooltip!",
    Button { "Hover me" }
}
Tooltip { label: "Top tooltip", position: "top",
    Button { "Top" }
}
}

Data Display

ComponentDescription
AvatarUser avatar with image or initials
BadgeSmall status indicator
Card / CardSectionContainer with sections
PaperCard-like container with shadow
DividerHorizontal/vertical separator
ImageResponsive image with fallback
List / ListItemStyled list
#![allow(unused)]
fn main() {
// Avatar
Avatar { name: "John Doe" }  // Shows "JD"
Avatar { size: "lg", color: "cyan", name: "Jane Smith" }
Avatar { src: "https://example.com/photo.jpg" }

// Badge
Badge { "New" }
Badge { variant: "light", color: "green", "Active" }
Badge { variant: "outline", color: "red", "Error" }
Badge { variant: "dot", "With dot" }

// Card
Card { shadow: "sm",
    CardSection { with_border: true,
        div { style: "padding: var(--rinch-spacing-sm);",
            Text { weight: "bold", "Card Title" }
        }
    }
    div { style: "padding: var(--rinch-spacing-md);",
        Text { "Card content goes here." }
    }
}

// Paper
Paper { shadow: "md", p: "lg",
    "Simple paper container"
}
Paper { shadow: "sm", p: "md", with_border: true,
    "Paper with border"
}

// Divider
Divider {}
Divider { label: "OR" }

// Image (local file or URL with image-network feature)
Image {
    src: "photo.png",
    width: "200",
    height: "150",
    fit: "cover",
    radius: "md",
}

// Avatar with image
Avatar { src: "photo.png", size: "lg" }

// List
List {
    ListItem { "First item" }
    ListItem { "Second item" }
    ListItem { "Third item" }
}
}
ComponentDescription
Tabs / TabsList / Tab / TabsPanelTab navigation
Accordion / AccordionItem / AccordionControl / AccordionPanelCollapsible sections
Breadcrumbs / BreadcrumbsItemNavigation trail
PaginationPage navigation
NavLinkNavigation link with active state
Stepper / StepperStep / StepperCompletedStep progress
#![allow(unused)]
fn main() {
// Tabs
Tabs {
    TabsList {
        Tab { value: "gallery", "Gallery" }
        Tab { value: "messages", "Messages" }
        Tab { value: "settings", "Settings" }
    }
    TabsPanel { value: "gallery",
        Text { "Gallery content" }
    }
    TabsPanel { value: "messages",
        Text { "Messages content" }
    }
}

// Tab variants
Tabs { variant: "outline", /* ... */ }
Tabs { variant: "pills", /* ... */ }

// Accordion
Accordion {
    AccordionItem { value: "item-1",
        AccordionControl { "What is Rinch?" }
        AccordionPanel {
            Text { "A reactive GUI framework for Rust." }
        }
    }
    AccordionItem { value: "item-2",
        AccordionControl { "How do I get started?" }
        AccordionPanel {
            Text { "Add rinch to your Cargo.toml!" }
        }
    }
}

// Breadcrumbs
Breadcrumbs {
    BreadcrumbsItem { "Home" }
    BreadcrumbsItem { "Products" }
    BreadcrumbsItem { "Component" }
}
Breadcrumbs { separator: ">",
    BreadcrumbsItem { "Docs" }
    BreadcrumbsItem { "API" }
}

// Pagination
Pagination {}
Pagination { with_edges: true }

// NavLink
NavLink { label: "Dashboard", active: true }
NavLink { label: "Settings", description: "App configuration" }
NavLink { label: "Disabled", disabled: true }
NavLink { label: "With handler", onclick: move || navigate("/page") }

// Stepper. Each step's state follows its position against `active`, and
// `{|| …}` makes the whole stepper follow the signal.
let step = Signal::new(1u32);
Stepper { active: {|| step.get()},
    StepperStep { label: "Account", description: "Create account" }
    StepperStep { label: "Verify", description: "Verify email" }
    StepperStep { label: "Complete", description: "Get started" }
}
}

Overlays

ComponentDescription
ModalDialog overlay
DrawerSlide-out panel
NotificationToast notification
Popover / PopoverTarget / PopoverDropdownPositioned popup
DropdownMenu / DropdownMenuTarget / DropdownMenuDropdown / DropdownMenuItemDropdown menu
HoverCard / HoverCardTarget / HoverCardDropdownCard on hover
LoadingOverlayLoading overlay for containers

Modal, Drawer, and the top-anchored Notification positions automatically clear any window chrome rinch draws (the Linux in-app menu bar, the BorderlessWindow titlebar) by offsetting with --rinch-window-top-inset. The invisible click-catching backdrops behind DropdownMenu and Select deliberately do not — they must catch clicks on the chrome too.

If you hand-roll a position: fixed overlay, offset it the same way (top: var(--rinch-window-top-inset, 0px)); fixed resolves against the real viewport, so a bare top: 0 renders underneath the chrome. See Theming → Window Chrome Inset.

An overlay that animates must stay rendered. Hide it with visibility or opacity and animate transform; do not toggle display. An element that was not being rendered has no before-change style, so un-hiding it and retargeting a transitioned property in the same style pass animates nothing — on desktop and in the browser, which refuses the same shape (that is what @starting-style and transition-behavior: allow-discrete exist for). Drawer and Popover are the two worked examples: both keep their panel rendered while closed, Drawer slides its transform and Popover fades its opacity. See the transitions section of the rendering pipeline.

To animate the close as well, delay the hide. Put transition: visibility 0s linear <duration> on the hidden state only: the box stays visible through the transition’s delay, then hides, and the shown state — which declares no visibility transition — cancels a pending hide if the overlay reopens first. Drawer and Popover both do this. visibility is transitionable on desktop since issue #759, interpolated as CSS says (every step strictly inside a transition with a visible end is visible), and unlike any other property there its animated value reaches the descendants that inherit it, so the panel’s content stays on screen with it.

A visibility: hidden subtree is excluded from paint, from hit testing and from the Tab order, which is most of what display: none was doing — but not all of it, and the difference is that the box is still there. Two consequences to know before you reach for it are below.

On desktop, “excluded from paint” covers the box’s background, border and shadow, and also its text, underline (wavy too) and line-through, text-shadow, inline backgrounds, SVG shapes, scrollbar thumbs, RenderSurface frame, filter overlay and read-only selection highlight (desktop drew the text until issue #829). Three known exceptions remain: a hidden span inside a nowrap or pre root that text-overflow: ellipsis cuts is still drawn (#853; a wrapping root holding one is clipped without a “…” instead), a hidden data-viewport still cuts its hole in its ancestors’ backgrounds (#854), and a ligature that straddles a visibility boundary follows the wrong end of it (#852). As in CSS, a descendant that declares visibility: visible is painted again.

  • It still takes part in layout, so a hidden overlay that is an in-flow child of an overflow: auto container contributes its box to that container’s scrollable extent, and the container grows a scrollbar for content nobody can see — as it would in a browser. A position: fixed overlay (Modal, Drawer) is exempt, since #765: a fixed box is no part of any scroll range below the viewport.
  • A descendant may declare visibility: visible, and the three readers do not agree about what that means. Paint honours it (the node is drawn), the Tab order honours it (collect_focusable_nodes_from tests each node on its own), and hit testing does not — it rejects the whole subtree at the hidden ancestor before it ever reaches the descendant. So such a node is painted and tabbable but not clickable (#843).

Neither is a reason to go back to display, which cannot animate at all. They are the two places to look when a closed overlay misbehaves.

#![allow(unused)]
fn main() {
// Modal
let modal_open = Signal::new(false);

Button { onclick: move || modal_open.set(true), "Open Modal" }

Modal {
    opened: modal_open.get(),
    onclose: move || modal_open.set(false),
    title: "Confirm Action",
    size: "md",

    Text { "Are you sure you want to proceed?" }
    Space { h: "lg" }
    Group { justify: "end", gap: "sm",
        Button { variant: "outline", "Cancel" }
        Button { "Confirm" }
    }
}

// Drawer
Drawer {
    opened: drawer_open.get(),
    onclose: move || drawer_close.set(false),
    title: "Settings",
    position: "right",  // left, right, top, bottom
    size: "sm",

    // Drawer content
}

// Notification
Notification {
    opened: show_notification.get(),
    title: "Success!",
    color: "green",
    position: "top-right",
    onclose: move || close_notification.set(false),
    "Your changes have been saved."
}

// Popover
Popover { position: "bottom",
    PopoverTarget {
        Button { "Click me" }
    }
    PopoverDropdown {
        Text { "Popover content!" }
        Button { size: "xs", "Action" }
    }
}

// DropdownMenu
DropdownMenu {
    DropdownMenuTarget {
        Button { "Menu" }
    }
    DropdownMenuDropdown {
        DropdownMenuLabel { "Application" }
        DropdownMenuItem { "Settings" }
        DropdownMenuItem { "Profile" }
        DropdownMenuDivider {}
        DropdownMenuItem { color: "red", "Delete" }
    }
}

// HoverCard
HoverCard { position: "bottom",
    HoverCardTarget {
        Text { weight: "bold", "@username" }
    }
    HoverCardDropdown {
        Group { gap: "sm",
            Avatar { name: "User Name" }
            Stack { gap: "xs",
                Text { weight: "bold", "User Name" }
                Text { size: "sm", color: "dimmed", "Bio here" }
            }
        }
    }
}

// LoadingOverlay — the `position: relative` on the container is REQUIRED.
// Without it the overlay's containing block is the nearest positioned ancestor
// further up — or the viewport, and it covers the whole window (the CSS rule,
// and what rinch-web has always done).
div { style: "position: relative; height: 200px;",
    // Your content
    LoadingOverlay { visible: is_loading.get() }
}
}

Window Components

ComponentDescription
BorderlessWindowContainer for borderless/transparent windows with custom titlebar
use rinch::prelude::*;
use std::rc::Rc;

#[component]
fn app() -> NodeHandle {
    // For custom left section (e.g., menu button)
    let menu_open = Signal::new(false);
    let left_section: SectionRenderer = Rc::new(move |__scope| {
        rsx! {
            ActionIcon { onclick: move || menu_open.update(|v| *v = !*v),
                "☰"
            }
        }
    });

    rsx! {
        BorderlessWindow {
            title: "My App",
            radius: "md",  // none, xs, sm, md, lg, xl
            left_section: Some(left_section),
            on_minimize: || minimize_current_window(),
            on_maximize: || toggle_maximize_current_window(),
            on_close: || close_current_window(),

            // Your content here
            Stack { gap: "md",
                Title { order: 2, "Welcome!" }
                Text { "This is a borderless window." }
            }
        }
    }
}

fn main() {
    let window_props = WindowProps {
        title: "My App".into(),
        borderless: true,
        transparent: true,
        resize_inset: Some(8.0),
        ..Default::default()
    };

    App::new(app).window_props(window_props).run();
}

BorderlessWindow Props:

PropTypeDescription
titleOption<String>Window title in titlebar
radiusOption<String>Corner radius: none, xs, sm, md, lg, xl
show_minimizeboolShow minimize button (default: true)
show_maximizeboolShow maximize button (default: true)
show_closeboolShow close button (default: true)
left_sectionOption<SectionRenderer>Custom content for titlebar left
right_sectionOption<SectionRenderer>Custom content before controls
on_minimizeOption<Callback>Minimize callback
on_maximizeOption<Callback>Maximize callback
on_closeOption<Callback>Close callback

The component provides:

  • Rounded corners with configurable radius
  • Draggable titlebar (via app-region: drag)
  • Window control buttons with hover effects
  • Left/right custom sections for menu buttons or additional controls
  • Proper theming via CSS variables
  • --rinch-window-top-inset published for overlays (36px for the titlebar, plus the menu bar height when one sits below it)

The titlebar is an in-flow element, so your content sits below it automatically. A position: fixed overlay does not — it resolves against the real viewport and would cover the titlebar and its close button. Offset such overlays by var(--rinch-window-top-inset, 0px); see Theming → Window Chrome Inset.

Building Custom Components

See the dedicated Writing Components guide for the recommended approach using #[component] PascalCase functions. The manual Component trait approach below is only needed for advanced use cases.

Building Custom Component Crates (Advanced)

You can create your own component library crate that integrates with Rinch’s theme system.

1. Create the Crate

# Cargo.toml
[package]
name = "my-components"
version = "0.1.0"
edition = "2021"

[dependencies]
rinch-core = { path = "../rinch-core" }

2. Implement the Component Trait

Each component must implement the Component trait from rinch-core. Components receive a RenderScope for DOM construction and return a NodeHandle:

#![allow(unused)]
fn main() {
use rinch_core::{Component, NodeHandle, RenderScope};

/// A custom card component.
#[derive(Debug, Default)]
pub struct MyCard {
    /// Card title.
    pub title: Option<String>,
    /// Card variant.
    pub variant: Option<String>,
    /// Whether to show shadow.
    pub shadow: bool,
}

impl Component for MyCard {
    fn render(&self, scope: &mut RenderScope, children: &[NodeHandle]) -> NodeHandle {
        // Create the card element
        let card = scope.create_element("div");

        // Build CSS classes
        let mut classes = vec!["my-card".to_string()];

        if let Some(ref v) = self.variant {
            match v.as_str() {
                "elevated" => classes.push("my-card--elevated".to_string()),
                "outlined" => classes.push("my-card--outlined".to_string()),
                _ => {}
            }
        }

        if self.shadow {
            classes.push("my-card--shadow".to_string());
        }

        card.set_attribute("class", &classes.join(" "));

        // Add title if provided
        if let Some(ref title) = self.title {
            let title_div = scope.create_element("div");
            title_div.set_attribute("class", "my-card__title");
            title_div.set_text(title);
            card.append_child(&title_div);
        }

        // Append children
        for child in children {
            card.append_child(child);
        }

        card
    }
}
}

3. Add Event Handlers

For interactive components, add event listeners directly to DOM nodes:

#![allow(unused)]
fn main() {
use rinch_core::Callback;

pub struct MyButton {
    pub onclick: Option<Callback>,
}

impl Component for MyButton {
    fn render(&self, scope: &mut RenderScope, children: &[NodeHandle]) -> NodeHandle {
        let button = scope.create_element("button");
        button.set_attribute("class", "my-button");

        // Add click handler if provided
        if let Some(ref cb) = self.onclick {
            let cb = cb.clone();
            button.add_event_listener("click", move |_| cb.invoke());
        }

        // Append children
        for child in children {
            button.append_child(child);
        }

        button
    }
}
}

invoke runs the app’s handler untracked, so it is safe to call from inside one of your component’s effects too: whatever signals the handler reads, your effect is not subscribed to them. See Reactivity.

4. Create CSS Styles

Create a styles module that generates CSS for your components:

#![allow(unused)]
fn main() {
// src/styles/mod.rs
mod card;
mod button;

pub fn generate_all_styles() -> String {
    let mut css = String::new();
    css.push_str(&card::styles());
    css.push_str(&button::styles());
    css
}

// src/styles/card.rs
pub fn styles() -> String {
    r#"
.my-card {
    background: var(--rinch-color-body);
    border-radius: var(--rinch-radius-default);
    padding: var(--rinch-spacing-md);
}

.my-card--elevated {
    box-shadow: var(--rinch-shadow-md);
}

.my-card--outlined {
    border: 1px solid var(--rinch-color-border);
}

.my-card__title {
    font-size: var(--rinch-font-size-lg);
    font-weight: 600;
    margin-bottom: var(--rinch-spacing-sm);
}
"#.to_string()
}
}

5. Export Your Components

#![allow(unused)]
fn main() {
// src/lib.rs
pub mod styles;

mod card;
mod button;

pub use card::MyCard;
pub use button::MyButton;

// Re-export Component trait for convenience
pub use rinch_core::Component;

/// Generate all component CSS.
pub fn generate_css() -> String {
    styles::generate_all_styles()
}
}

6. Use Theme CSS Variables

Leverage Rinch’s theme variables for consistency:

VariableDescription
--rinch-primary-colorPrimary theme color
--rinch-color-bodyBackground color
--rinch-color-textText color
--rinch-color-dimmedDimmed/muted text
--rinch-color-borderBorder color
--rinch-color-{name}-{0-9}Color shades (e.g., --rinch-color-blue-5)
--rinch-spacing-{xs,sm,md,lg,xl}Spacing scale
--rinch-radius-{xs,sm,md,lg,xl}Border radius
--rinch-radius-defaultDefault radius
--rinch-shadow-{xs,sm,md,lg,xl}Box shadows
--rinch-font-size-{xs,sm,md,lg,xl}Font sizes
--rinch-font-familyDefault font
--rinch-font-family-monospaceMonospace font
--rinch-window-top-insetHeight of the window chrome rinch draws above your content. Not theme-generated — published at runtime by the in-app menu bar / BorderlessWindow titlebar. A position: fixed overlay should offset by it (top: var(--rinch-window-top-inset, 0px)), since fixed resolves against the real viewport and would otherwise slide underneath the chrome. See Theming

7. Use Your Component Crate

use rinch::prelude::*;
use my_components::{MyCard, MyButton};

#[component]
fn app() -> NodeHandle {
    rsx! {
        MyCard { title: "Hello", variant: "elevated", shadow: true,
            Text { "Card content here" }
            MyButton { onclick: move || println!("Clicked!"),
                "Click Me"
            }
        }
    }
}

fn main() {
    let theme = ThemeProviderProps {
        primary_color: Some("blue".into()),
        ..Default::default()
    };
    App::new(app)
        .title("Custom Components")
        .size(800, 600)
        .theme(theme)
        .run();
}

Component CSS Injection

Component CSS is automatically injected when you use ThemeProvider. If you need the CSS separately:

#![allow(unused)]
fn main() {
use rinch::components::generate_component_css;

let css = generate_component_css();
}

For custom component crates, inject your CSS alongside the theme:

#![allow(unused)]
fn main() {
use my_components::generate_css;

// Add to your HTML head or inject via ThemeProvider
let custom_css = generate_css();
}

Custom Components with #[component]

You can create reusable custom components using the #[component] macro. Components written in PascalCase become usable components in RSX:

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

#[component]
pub fn MyCard(
    title: String,
    color: String,
    children: &[NodeHandle],
) -> NodeHandle {
    rsx! {
        div { class: "my-card",
            h2 { {title.clone()} }
            div { class: "card-body", style: {move || format!("color: {}", color.clone())},
                children
            }
        }
    }
}

// Use in RSX:
#[component]
fn app() -> NodeHandle {
    rsx! {
        MyCard { title: "Hello", color: "blue",
            Text { "Card content here" }
        }
    }
}
}

Key points:

  • PascalCase function names become components in RSX
  • children: &[NodeHandle] is a special parameter that receives child content
  • Use children in the body to render child elements
  • #[component] auto-injects __scope: &mut RenderScope as the first parameter
  • Props are regular Rust function parameters

Without children:

#![allow(unused)]
fn main() {
#[component]
pub fn StatusBadge(label: String, status: String) -> NodeHandle {
    rsx! {
        div { class: "badge",
            style: {move || format!("background: {}",
                if status == "success" { "green" } else { "red" })},
            {label.clone()}
        }
    }
}

// Use:
StatusBadge { label: "Active", status: "success" }
}

With callback props:

Callback and InputCallback have built-in defaults (no-op), so they work as direct props without Option wrapping:

#![allow(unused)]
fn main() {
#[component]
pub fn TodoItem(
    label: String,
    completed: bool,
    on_toggle: Callback,
    on_delete: Callback,
) -> NodeHandle {
    rsx! {
        Group { gap: "sm", align: "center",
            Checkbox {
                checked: completed,
                onchange: on_toggle,  // Forward directly — no Option unwrapping needed
            }
            Text { {label.clone()} }
            ActionIcon { variant: "subtle", color: "red",
                onclick: on_delete,
                "X"
            }
        }
    }
}

// Use — closures are auto-wrapped into Callback:
TodoItem {
    label: "Buy groceries",
    completed: false,
    on_toggle: move || toggle(id),
    on_delete: move || delete(id),
}
}

Prop type defaults:

The #[component] macro generates a Default impl for the component struct. Known types get sensible defaults automatically:

TypeDefault
StringString::new() (empty)
boolfalse
Option<T>None
Vec<T>Vec::new()
Numeric types0 / 0.0
CallbackNo-op (does nothing)
InputCallbackNo-op (does nothing)

Other types (e.g., custom structs) fall back to Default::default(), so they must implement Default.

Icon System

Rinch provides a type-safe icon system built on Tabler Icons. Instead of passing arbitrary HTML strings, components accept TablerIcon enum values for discoverability and consistency.

TablerIcon lives in the rinch-tabler-icons crate and is not re-exported through the rinch prelude, so add it alongside rinch:

rinch-tabler-icons = { workspace = true }

Older snippets may show a curated Icon enum from rinch-core. That enum no longer exists — every icon prop takes Option<TablerIcon>. A few names were spelled differently: Icon::CheckCircle is now TablerIcon::CircleCheck, and Icon::XCircle is TablerIcon::CircleX.

Basic Usage

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

#[component]
fn app() -> NodeHandle {
    rsx! {
        Alert { icon: TablerIcon::InfoCircle, color: "blue", title: "Information",
            "This is an informational message."
        }

        Alert { icon: TablerIcon::CircleCheck, color: "green", title: "Success",
            "Operation completed successfully."
        }

        Alert { icon: TablerIcon::AlertTriangle, color: "yellow", title: "Warning",
            "Please review this carefully."
        }

        Alert { icon: TablerIcon::CircleX, color: "red", title: "Error",
            "Something went wrong."
        }
    }
}
}

Available Icons

All 4,983 Tabler icons are available as enum variants — browse them at tabler.io/icons, or iterate ALL_ICONS (with ICON_COUNT) to build a picker. A sample of the common ones:

CategoryIcons
NavigationHome, ChevronUp, ChevronDown, ArrowLeft, ArrowRight, Menu2
ActionsPlus, Minus, X, Check, Search, Settings, Edit, Trash
Status/AlertsInfoCircle, CircleCheck, AlertCircle, AlertTriangle, CircleX
ContentUser, Mail, Phone, File, Folder, Photo, Copy
UIBell, Message, Send, Share, Download, Upload

Every icon has an Outline form; roughly a thousand also have real Filled artwork, and asking for TablerIconStyle::Filled on one that doesn’t falls back to its outline paths. See Icon System in CLAUDE.md for rendering icons standalone with render_tabler_icon.

Components with Icon Support

Every prop below is Option<TablerIcon>; rsx! supplies the Some(...), so write icon: TablerIcon::Check, not icon: Some(TablerIcon::Check).

ComponentIcon Props
ActionIconicon
Alerticon
Notificationicon
AccordionControlicon
Blockquoteicon
Listicon
ListItemicon
Steppercompleted_icon, progress_icon
StepperStepicon, completed_icon, progress_icon
NavLinkleft_section, right_section
DropdownMenuItemleft_section, right_section
Tableft_section, right_section

Tree takes its icons through data instead: TreeNodeData::icon, set with the with_icon(TablerIcon) builder.

List::icon and the two Stepper icons are container defaults for the items that set none — the item’s own icon always wins — and were declared but wired to nothing until #707. The container patches its items after they have rendered, because a parent component renders after its children, and it patches whatever lands beneath it afterwards too: an item a for reconcile appends, a show_dom branch reveals, or a hand-rolled append_child adds takes the default as it lands (issue #716). An item moved from one container into another re-resolves against the one it now belongs to. Stepper goes one further and re-derives when an item leaves as well (issue #745), because its answer is a step’s position: dropping a step, or moving one out, renumbers and restates every step behind it. Stepper::progress_icon stands in for the progress_icon a step did not set, so it outranks that step’s plain icon, exactly as the step’s own progress_icon would have.

Examples

#![allow(unused)]
fn main() {
// NavLink with icons
NavLink {
    label: "Dashboard",
    left_section: TablerIcon::Settings,
    active: true
}

// Tab with icon
Tabs {
    TabsList {
        Tab { value: "home", left_section: TablerIcon::User, "Profile" }
        Tab { value: "settings", left_section: TablerIcon::Settings, "Settings" }
    }
}

// Dropdown menu items with icons
DropdownMenu {
    DropdownMenuTarget {
        Button { "Actions" }
    }
    DropdownMenuDropdown {
        DropdownMenuItem { left_section: TablerIcon::Edit, "Edit" }
        DropdownMenuItem { left_section: TablerIcon::Trash, color: "red", "Delete" }
    }
}

// Blockquote with quote icon
Blockquote { icon: TablerIcon::Quote, cite: "— Unknown",
    "The best code is no code at all."
}

// Stepper with custom icons. Each step's state comes from its position
// against `active`, so "Account" is completed and draws the CircleCheck,
// "Verify" is in progress and draws `2`, and "Complete" draws `3`.
Stepper { active: 1, completed_icon: TablerIcon::CircleCheck,
    StepperStep { label: "Account" }
    StepperStep { label: "Verify" }
    StepperStep { label: "Complete" }
}
}

Benefits of the Icon System

  1. Type Safety: Compiler catches typos and invalid icon names
  2. Discoverability: IDE autocomplete shows all available icons
  3. Consistency: All icons use the same SVG style and sizing
  4. Performance: Icons render as structured nodes, enabling differential updates instead of full re-renders

Reactive Component Props

Rinch supports two mechanisms for making component props reactive:

1. Reactive Closures on Any Prop (Re-renders Component)

Pass a closure {|| expr} to any component prop (like variant, color, size, disabled). When signals inside the closure change, the entire component re-renders with the new prop values:

#![allow(unused)]
fn main() {
let active = Signal::new(false);

rsx! {
    Button {
        variant: {|| if active.get() { "filled" } else { "light" }},
        color: {|| if active.get() { "green" } else { "gray" }},
        onclick: move || active.update(|v| *v = !*v),
        "Toggle me"
    }
}
}

This works on all component props — the macro detects closures and wraps the component in a reactive scope automatically.

2. _fn Props (Surgical DOM Updates)

For components that support _fn props, these provide more efficient updates that only touch the specific DOM node, without re-rendering the entire component. The rsx! macro auto-wraps _fn props — just pass a closure directly:

#![allow(unused)]
fn main() {
let is_checked = Signal::new(false);
let input_text = Signal::new(String::new());

rsx! {
    // Auto-wrapped: just pass a closure, no Rc::new() needed
    Checkbox {
        label: "Enable feature",
        checked_fn: move || is_checked.get(),
        onchange: move || is_checked.update(|v| *v = !*v),
    }

    TextInput {
        placeholder: "Type here...",
        value_fn: move || input_text.get(),
        oninput: move |value: String| input_text.set(value),
    }
}
}

Reactive Props Summary

| Prop Type | Accepts {|| ...} | Update Strategy | |—|—|—| | HTML element attributes | Yes | Surgical DOM update | | Component style:/class:, style shorthands, hyphenated attributes | Yes | Surgical DOM update (also on a component that re-renders for a struct prop) | | Component _fn props | Yes (auto-wrapped) | Surgical DOM update | | Component props (all others) | Yes | Full component re-render |

Components with _fn Props

ComponentReactive PropPurpose
Checkboxchecked_fnToggle checked class reactively
Switchchecked_fnToggle checked class reactively
Radiochecked_fnToggle checked class reactively
TextInputvalue_fnReactive value binding
NavLinkactive_fnToggle active class reactively
Progressvalue_fnUpdate progress bar width reactively
Modalopened_fnShow/hide modal reactively
Draweropened_fnShow/hide drawer reactively
Notificationopened_fnShow/hide notification reactively

Controlled Input Pattern

For controlled inputs, use value_fn + oninput together. value_fn keeps the DOM in sync with your signal; oninput updates the signal from user input. Without value_fn, programmatic signal.set("") won’t clear the input visually:

#![allow(unused)]
fn main() {
let text = Signal::new(String::new());

rsx! {
    TextInput {
        value_fn: move || text.get(),
        oninput: move |value: String| text.set(value),
        onsubmit: move || {
            println!("Submitted: {}", text.get());
            text.set(String::new());  // Clears the input thanks to value_fn
        },
    }
}
}

The echo is not written back. In this pattern every keystroke runs oninput → signal → value_fn, which returns the very text the field already shows. TextInput, PasswordInput, Textarea and NumberInput compare value_fn’s result with the field’s live text (NodeHandle::live_value, issue #238) and write only when they differ — a normalising handler’s rewrite, or a programmatic change. The comparison is against the live text, not the value attribute, which on the web still holds the last programmatic write while the user types.

Writing to the field the user is typing in. A value_fn write (or any set_attribute("value")) that lands on the focused field is adopted by the field on both backends (issue #238): it becomes the text the next keystroke edits, the caret keeps its logical position through the rewrite (a kept prefix or suffix keeps the caret with it, a same-length rewrite such as a case change leaves it in place, a resized rewrite puts it after the new text), a selection survives, and the write is deferred while an IME composition is in flight (applied when the composition ends). A programmatic write never commits onchange by itself — only a user edit in the gesture does. So a normalizing or rejecting oninput is safe to write back on every keystroke:

#![allow(unused)]
fn main() {
let digits = Signal::new(String::new());

rsx! {
    TextInput {
        value_fn: move || digits.get(),
        // Digits only: the rejected character never sticks, and the caret
        // stays put — on desktop as well as on the web.
        oninput: move |value: String| {
            digits.set(value.chars().filter(char::is_ascii_digit).collect())
        },
    }
}
}

Text inputs (TextInput, Textarea, PasswordInput, NumberInput) also take an onchange prop — the commit boundary (issue #226): it fires once with the final value when the typed gesture ends (focus leaves the control after a modification, or Enter — blur-only for Textarea), and only if the user modified it during the gesture — a programmatic write to a still-untouched field moves the reference point with it, matching the browser’s dirty flag. Keep oninput for the controlled-input signal sync; use onchange for validate-on-commit and autosave-on-leave.

Customization

Components automatically respond to theme settings:

#![allow(unused)]
fn main() {
let theme = ThemeProviderProps {
    primary_color: Some("violet".into()),  // All primary-colored components use violet
    default_radius: Some("lg".into()),     // Larger border radius everywhere
    dark_mode: true,                       // Dark theme colors
    ..Default::default()
};

// Components automatically adapt to the theme
App::new(app)
    .title("My App")
    .size(800, 600)
    .theme(theme)
    .run();
}