Freya 0.4

4/2/2026 - marc2332

hey

Hey again, this is the announcement of Freya v0.4, the latest release of my Rust ๐Ÿฆ€ GUI library.

It has been around a year since v0.3 and this is by far the biggest release yet. Freya 0.4 comes with a big rewrite, the most notable change is that Freya no longer depends on Dioxus. Instead, it now has its own reactive and component model built from scratch.

For the full changelog you can check the v0.4 Release on GitHub.

๐Ÿ”„ Why the rewrite

Freya originally used Dioxus as its reactive and component engine. Dioxus served Freya really well during the early versions and I am grateful for the foundation it provided. But as Freya grew, the limitations started to show up.

Dioxus is primarily designed around web, things like the rsx!() macro, string-based attributes, and the way components and elements were defined made it harder to provide the kind of type safety, extensibility and simplicity I wanted. Also, depending on a large external framework meant that Freya would be affected by any upstream change and design decision, even if those were not aligned with Freyaโ€™s direction.

So I decided to build Freyaโ€™s own reactive system from scratch. The new system is heavily inspired by Dioxus (and React), but tailored specifically for Freyaโ€™s needs. It still has hooks, callback event handlers, and a very similar state and async model. But now Freya owns the full stack and can evolve freely.

In practice this also means a nicer day-to-day: typos in attributes are caught by the compiler instead of at runtime, your IDE actually autocompletes things, and stack traces point at the line you wrote instead of an expanded macro.

A few crates that previously came from the Dioxus ecosystem have been forked and adapted:

  • dioxus-radio became freya-radio
  • dioxus-query became freya-query
  • dioxus-i18n became freya-i18n
  • dioxus-clipboard became freya-clipboard

And many crates were reorganized, split, or consolidated:

  • New crates: freya-icons, freya-animation, freya-edit, freya-performance-plugin, freya-query, freya-terminal, freya-webview, freya-code-editor, freya-android, freya-material-design, freya-router, freya-router-macro, freya-sdk, freya-camera, freya-plotters-backend, freya-devtools-app, pathgraph, ragnarok
  • Removed/redistributed: freya-hooks, freya-native-core, freya-elements (merged into freya-core)

๐Ÿ—๏ธ The new API

The biggest user-facing change is the removal of the rsx!() macro. In its place, Freya now uses a sort of builder pattern with fully typed attributes.

Here is what a counter looked like in 0.3:

fn app() -> Element {
    let mut count = use_signal(|| 0);

    rsx!(
        rect {
            width: "fill",
            height: "50%",
            main_align: "center",
            cross_align: "center",
            color: "white",
            background: "rgb(15, 163, 242)",
            font_weight: "bold",
            font_size: "75",
            label { "{count}" }
        }
        rect {
            direction: "horizontal",
            width: "fill",
            height: "50%",
            main_align: "center",
            cross_align: "center",
            spacing: "8",
            Button {
                on_press: move |_| count += 1,
                label { "Increase" }
            }
            Button {
                on_press: move |_| count -= 1,
                label { "Decrease" }
            }
        }
    )
}

And here is the same counter in 0.4:

fn app() -> impl IntoElement {
    let mut count = use_state(|| 0);

    let counter = rect()
        .width(Size::fill())
        .height(Size::percent(50.))
        .center()
        .color((255, 255, 255))
        .background((15, 163, 242))
        .font_weight(FontWeight::BOLD)
        .font_size(75.)
        .child(count.read().to_string());

    let actions = rect()
        .horizontal()
        .width(Size::fill())
        .height(Size::percent(50.))
        .center()
        .spacing(8.0)
        .child(
            Button::new()
                .on_press(move |_| {
                    *count.write() += 1;
                })
                .child("Increase"),
        )
        .child(
            Button::new()
                .on_press(move |_| {
                    *count.write() -= 1;
                })
                .child("Decrease"),
        );

    rect().child(counter).child(actions)
}

A few things to notice:

  • No macro. Just regular Rust method calls with full IDE support (autocomplete, go-to-definition, etc.)
  • Attributes are fully typed. Instead of width: "fill" you write width(Size::fill()). Instead of font_weight: "bold" you write font_weight(FontWeight::BOLD). This means compile-time errors instead of runtime panics.
  • use_signal is now use_state.
  • Components return impl IntoElement instead of Element.
  • Strings and &str automatically convert to text labels, so "hello" is all you need for simple text, you can still use label() for more customization.
  • Helper methods like .center(), .horizontal(), and .expanded() make common layout patterns shorter.

๐Ÿงฑ Elements

Elements in Freya 0.4 are regular Rust functions that return builders. Each builder has typed methods for every attribute it supports. Here is a quick overview of the core elements:

rect() is still the general-purpose container. It supports layout, styling, text properties, events, transforms, and children:

rect()
    .width(Size::fill())
    .height(Size::px(200.))
    .padding(16.)
    .margin(8.)
    .spacing(12.)
    .background((240, 240, 240))
    .border(Border::new().fill((200, 200, 200)).width(1.0).alignment(BorderAlignment::Inner))
    .corner_radius(8.)
    .shadow((0., 2., 4., 0., (0, 0, 0, 25)))
    .opacity(0.9)
    .blur(2.0)
    .direction(Direction::Horizontal)
    .main_align(Alignment::SpaceBetween)
    .cross_align(Alignment::Center)
    .content(Content::Flex)
    .child("Hello!")

label() is for simple text:

label()
    .text("Hello, World!")
    .font_size(18.)
    .font_weight(FontWeight::BOLD)
    .color(Color::WHITE)
    .max_lines(1)
    .text_overflow(TextOverflow::Ellipsis)

paragraph() supports rich text with multiple spans and cursors:

paragraph()
    .cursor_color(Color::BLUE)
    .highlight_color((100, 149, 237, 100))
    .span(Span::new("Bold text").font_weight(FontWeight::BOLD))
    .span(Span::new(" and normal text"))

svg() renders SVG content with optional color and stroke overrides:

svg(include_bytes!("./icon.svg"))
    .color(Color::WHITE)
    .stroke_width(2.0)
    .width(Size::px(24.))
    .height(Size::px(24.))

Elements in 0.4 are no longer hardcoded into the framework, they live in user-land. Anyone can implement the ElementExt trait to define a fully custom element with its own layout, rendering, and diffing behavior. This is the exact same API that powers all of Freyaโ€™s built-in elements (rect, label, paragraph, svg, โ€ฆ) and also more specialized ones like GifViewer, WebView, etc. See feature_element.rs for a complete example.

๐Ÿงฉ Components

Components in Freya 0.4 are any data type that implements the Component trait. In practice, you define a struct with your props, derive PartialEq (so the framework can diff them), and implement render:

#[derive(PartialEq)]
struct Card(Task);

impl Component for Card {
    fn render(&self) -> impl IntoElement {
        let animation = use_animation(|conf| {
            conf.on_creation(OnCreation::Run);
            AnimNum::new(0.8, 1.)
                .time(500)
                .function(Function::Expo)
                .ease(Ease::Out)
        });

        rect()
            .background((255, 255, 255))
            .border(Border::new().fill((200, 200, 200)).width(1.0).alignment(BorderAlignment::Inner))
            .corner_radius(4.0)
            .padding(12.0)
            .width(Size::px(200.))
            .height(Size::px(60.))
            .scale(animation.read().value())
            .shadow((0., 2., 4., 0., (0, 0, 0, 25)))
            .child(label().text(self.0.title.clone()))
    }
}

The render_key() method helps the framework with reconciliation when rendering lists of components. This is similar to the key prop in React:

impl Component for StoryItem {
    fn render(&self) -> impl IntoElement {
        // ...
    }

    fn render_key(&self) -> DiffKey {
        DiffKey::from(&self.id)
    }
}

For simple cases, you can still use plain functions that return impl IntoElement. And for the root component of your app, there is also an App trait:

struct MyApp {
    value: u8,
}

impl App for MyApp {
    fn render(&self) -> impl IntoElement {
        format!("Value is {}", self.value)
    }
}

fn main() {
    launch(LaunchConfig::new().with_window(WindowConfig::new_app(MyApp { value: 4 })))
}

๐Ÿ”Œ State management

use_state

The core of state management is use_state. It creates reactive state that automatically triggers re-renders when modified:

let mut count = use_state(|| 0);

// Reading (subscribes the component to changes)
let value = count.read();
let value = count();  // Same as .read(), shorthand for Copy types

// Reading without subscribing
let value = count.peek();

// Writing (triggers re-renders)
*count.write() += 1;
count.set(42);
count.toggle();  // For booleans
count.set_if_modified(new_value);  // Only updates if value differs
count.with_mut(|val| *val += 1);  // Modify with closure

// Silent write (no re-render)
*count.write_silently() = 99;

For multi-window apps, you can create global state that lives outside any component:

let count = State::create_global(0);

use_memo

Memoize expensive computations that automatically rerun when their dependencies change:

let doubled = use_memo(move || count.read() * 2);

use_side_effect

Run side effects when reactive values change:

use_side_effect(move || {
    println!("Count changed to: {}", count.read());
});

There is also use_side_effect_with_deps for explicit dependency tracking:

use_side_effect_with_deps(&some_prop, move |prop| {
    println!("Prop changed to: {:?}", prop);
});

use_future

Manage async tasks with loading states:

let data = use_future(move || async move {
    fetch_data().await
});

let message = match data.state().as_ref() {
    FutureState::Pending => "Not started".into_element(),
    FutureState::Loading => "Loading...".into_element(),
    FutureState::Fulfilled(result) => format!("Got: {result}").into_element(),
};

Context

Pass data down the component tree without prop drilling:

// Provider
use_provide_context(|| MyThemeConfig::default());

// Consumer (anywhere in the subtree)
let theme = use_consume::<MyThemeConfig>();

// Optional consumer
let maybe_theme = use_try_consume::<MyThemeConfig>();

๐ŸŽญ Dynamic rendering

Since there is no macro, dynamic rendering works through regular Rust patterns. You can use .maybe_child() for conditional children:

fn app() -> impl IntoElement {
    let mut show = use_state(|| false);

    rect().center().expanded().child(
        Attached::new(
            Button::new()
                .child("Toggle")
                .on_press(move |_| show.toggle()),
        )
        .maybe_child(show().then(|| rect().child(Button::new().child("Attached")))),
    )
}

And .children() accepts iterators for dynamic lists:

rect().children((0..5).map(|i| {
    label().key(i).text(format!("Item {i}")).into()
}))

The .map() method lets you conditionally modify an element based on a value:

Button::new()
    .child(story.title.clone())
    .map(url, |el, url| {
        el.on_press(move |_| {
            let _ = open::that(&url);
        })
    })

๐ŸŽฏ Events

Freya has a comprehensive event system with typed event data. Events are handled through builder methods, and every handler receives a strongly-typed Event<...EventData>:

rect()
    .on_press(|e: Event<PressEventData>| {
        e.stop_propagation();
    })
    .on_mouse_down(|e: Event<MouseEventData>| {
        let _pos = e.element_location;
    })
    .on_key_down(|e: Event<KeyboardEventData>| {
        if e.key == Key::Named(NamedKey::Enter) {
            // ...
        }
    })

The same pattern is available for the rest of the events:

  • Pointer: .on_pointer_enter, .on_pointer_leave, .on_pointer_down, โ€ฆ (unified mouse + touch)
  • Mouse: .on_mouse_up, .on_mouse_move, โ€ฆ
  • Wheel: .on_wheel
  • Touch: .on_touch_start, .on_touch_move, .on_touch_end
  • File drag and drop: .on_file_drop, .on_global_file_hover, .on_global_file_hover_cancelled
  • Layout: .on_sized (fires when an elementโ€™s measured area or inner content size changes)

There are also global event variants like .on_global_pointer_press() and .on_global_key_down() that fire regardless of which element has focus.

๐ŸชŸ Multi-window support

Freya 0.4 supports multiple windows with shared state. You can create global state that is accessible from any window and spawn new windows at runtime:

fn main() {
    let count = State::create_global(0);
    launch(LaunchConfig::new().with_window(WindowConfig::new(move || app(count))))
}

fn app(count: State<i32>) -> impl IntoElement {
    let on_open = move |_| {
        spawn(async move {
            Platform::get()
                .launch_window(WindowConfig::new(move || sub_app(count)))
                .await;
        });
    };

    rect()
        .expanded()
        .center()
        .child(Button::new().on_press(on_open).child("Open"))
}

sub_app is just another function returning impl IntoElement. All windows share the same count state, so incrementing from one window updates the others.

WindowConfig provides configuration for the most common settings you will need for windows:

WindowConfig::new(app)
    .with_size(800., 600.)
    .with_min_size(400., 300.)
    .with_max_size(1920., 1080.)
    .with_title("My App")
    .with_decorations(false)
    .with_transparency(true)
    .with_background(Color::TRANSPARENT)
    .with_resizable(true)
    .with_icon(LaunchConfig::window_icon(ICON))
    .with_on_close(|ctx, window_id| {
        // Decide whether to close or keep open
        CloseDecision::Close
    })

๐Ÿ—‚๏ธ System tray

Freya now supports system tray icons with menus. You can run your app entirely from the tray, or combine tray functionality with regular windows:

fn main() {
    let tray_icon = || {
        let tray_menu = Menu::new();
        let _ = tray_menu.append(&MenuItem::with_id("open", "Open", true, None));
        let _ = tray_menu.append(&MenuItem::with_id(
            "toggle-visibility", "Toggle Visibility", true, None,
        ));
        let _ = tray_menu.append(&MenuItem::with_id("exit", "Exit", true, None));
        TrayIconBuilder::new()
            .with_menu(Box::new(tray_menu))
            .with_tooltip("Freya Tray")
            .with_icon(LaunchConfig::tray_icon(ICON))
            .build()
            .unwrap()
    };

    let tray_handler = |ev, mut ctx: RendererContext| match ev {
        TrayEvent::Menu(MenuEvent { id }) if id == "open" => {
            ctx.launch_window(WindowConfig::new(app).with_size(500., 450.));
        }
        // ... handle the other menu items (toggle visibility, exit, ...)
        _ => {}
    };

    launch(LaunchConfig::new().with_tray(tray_icon, tray_handler))
}

๐Ÿ—บ๏ธ Router

The freya-router crate provides client-side routing with nested layouts, route parameters, and programmatic navigation. Routes are defined with a derive macro:

#[derive(Routable, Clone, PartialEq)]
pub enum Route {
    #[layout(AppLayout)]
        #[route("/")]
        Home,
        #[route("/about")]
        About,
        #[nest("/users/:user_id")]
            #[layout(UserLayout)]
                #[route("/")]
                UserDetail { user_id: String },
                #[route("/posts")]
                UserPosts { user_id: String },
}

Each route variant maps to a component with the same name. Layout components wrap their children and render an Outlet for the active route:

#[derive(PartialEq)]
struct AppLayout;
impl Component for AppLayout {
    fn render(&self) -> impl IntoElement {
        rect()
            .native_router()
            .content(Content::flex())
            .child(
                rect()
                    .horizontal()
                    .height(Size::px(50.))
                    .background((230, 230, 230))
                    .padding(12.)
                    .spacing(12.)
                    .cross_align(Alignment::center())
                    .child(
                        ActivableRoute::new(
                            Route::Home,
                            Link::new(Route::Home).child(Button::new().flat().child("Home")),
                        )
                        .exact(true),
                    )
                    .child(
                        ActivableRoute::new(
                            Route::About,
                            Link::new(Route::About).child(Button::new().flat().child("About")),
                        )
                        .exact(true),
                    )
                    .child(rect().width(Size::flex(1.)))
                    .child(
                        Button::new()
                            .flat()
                            .on_press(|_| RouterContext::get().go_back())
                            .child("Go Back"),
                    ),
            )
            .child(rect().expanded().padding(12.).child(Outlet::<Route>::new())),
    }
}

๐Ÿ“ป Radio (global state)

For apps with complex state needs, the freya-radio crate provides a channel-based global state system. You define your state, your channels, and then subscribe individual components to specific channels. Only the components subscribed to a channel that gets notified will re-render:

#[derive(Default)]
struct Data {
    pub lists: Vec<Vec<String>>,
}

#[derive(PartialEq, Eq, Clone, Debug, Copy, Hash)]
pub enum DataChannel {
    ListCreation,
    SpecificListItemUpdate(usize),
}

impl RadioChannel<Data> for DataChannel {}

fn app() -> impl IntoElement {
    use_init_radio_station::<Data, DataChannel>(Data::default);
    let mut radio = use_radio::<Data, DataChannel>(DataChannel::ListCreation);

    rect()
        .horizontal()
        .child(
            Button::new()
                .on_press(move |_| radio.write().lists.push(Vec::default()))
                .child("Add new list"),
        )
        .children(
            radio
                .read()
                .lists
                .iter()
                .enumerate()
                .map(|(list_n, _)| ListComp(list_n).into()),
        )
}

#[derive(PartialEq)]
struct ListComp(usize);
impl Component for ListComp {
    fn render(&self) -> impl IntoElement {
        let list_n = self.0;
        let mut radio = use_radio::<Data, DataChannel>(DataChannel::SpecificListItemUpdate(list_n));

        rect()
            .child(
                Button::new()
                    .on_press(move |_| {
                        radio.write().lists[list_n].push("Hello, World".to_string())
                    })
                    .child("New Item"),
            )
            .children(
                radio.read().lists[list_n]
                    .iter()
                    .enumerate()
                    .map(move |(i, item)| label().key(i).text(item.clone()).into()),
            )
    }
}

Writing to radio state through radio.write() automatically notifies subscribers on the relevant channel. You can also select a specific channel to notify, or write silently without notifying anyone.

๐ŸŽจ Theming

Freya ships with a full theming system. Built-in themes include dark_theme and light_theme, and every built-in component respects the active theme:

fn app() -> impl IntoElement {
    use_init_theme(dark_theme);

    // All components in this tree will use the dark theme
    rect()
        .theme_background()  // Uses the theme's background color
        .child(Button::new().child("Themed button"))
}

The Theme struct contains a ColorsSheet with semantic colors (primary, secondary, success, warning, error, etc.), surface colors, border colors, text colors, and state colors (hover, focus, active, disabled). Each built-in component also has its own theme preferences for layout and colors.

You can switch themes at runtime and even detect the system preferred theme:

let platform = Platform::get();
let prefers_dark = *platform.preferred_theme.read() == PreferredTheme::Dark;

Theme helper methods on elements like .theme_background() and .theme_accent_color() make it easy to use theme colors without manual lookups.

๐Ÿ’ป Terminal embedding

The freya-terminal crate lets you embed a fully functional terminal emulator inside your Freya app. It supports mouse events, keyboard input, clipboard integration, window title updates, and more:

fn app() -> impl IntoElement {
    let mut handle = use_state(|| {
        let mut cmd = CommandBuilder::new("bash");
        cmd.env("TERM", "xterm-256color");
        cmd.env("COLORTERM", "truecolor");
        TerminalHandle::new(TerminalId::new(), cmd, None).ok()
    });

    let a11y_id = use_a11y();

    rect().expanded().child(if let Some(handle) = handle.read().clone() {
        Terminal::new(handle.clone())
            .a11y_id(a11y_id)
            .a11y_role(AccessibilityRole::Terminal)
            .a11y_auto_focus(true)
            .on_key_down(move |e: Event<KeyboardEventData>| {
                let _ = handle.write_key(&e.key, e.modifiers); 
            })
            .into_element()
    } else {
        "Terminal exited".into_element()
    })
}

Check the full terminal example for mouse handling, clipboard, and title tracking.

๐Ÿ” Loading data asynchronously with queries

The freya-query crate brings a system similar to tanstack-query for data fetching in Freya. You define queries with a run method that does a specific action, and the crate handles caching, loading states, re-fetching and error handling for you.

Here is a real example from the Hacker News app:

#[derive(Clone, PartialEq, Hash, Eq)]
struct GetStory;

impl QueryCapability for GetStory {
    type Ok = Story;
    type Err = Error;
    type Keys = i64;

    async fn run(&self, id: &Self::Keys) -> Result<Self::Ok, Self::Err> {
        let url = format!("https://hacker-news.firebaseio.com/v0/item/{}.json", id);
        let story = blocking::unblock(move || {
            let response = ureq::get(&url).call()?;
            let data = response.into_body().read_to_vec()?;
            serde_json::from_slice::<Story>(&data)
        }).await?;
        Ok(story)
    }
}

#[derive(PartialEq)]
struct StoryItem { id: i64 }

impl Component for StoryItem {
    fn render(&self) -> impl IntoElement {
        let story_query = use_query(
            Query::new(self.id, GetStory)
                .stale_time(Duration::from_secs(600))  // Cache for 10 minutes
        );

        match &*story_query.read().state() {
            QueryStateData::Pending | QueryStateData::Loading { .. } => {
                rect().center().child("Loading story...").into_element()
            }
            QueryStateData::Settled { res: Ok(story), .. } => {
                Button::new()
                    .width(Size::fill())
                    .child(
                        rect()
                            .padding((8.0, 16.0))
                            .child(story.title.clone())
                            .child(format!("{} points by {}", story.score, story.by))
                    )
                    .into_element()
            }
            QueryStateData::Settled { res: Err(e), .. } => {
                rect().color((255, 0, 0)).child(format!("Error: {}", e)).into_element()
            }
        }
    }

    fn render_key(&self) -> DiffKey {
        DiffKey::from(&self.id)
    }
}

๐Ÿ–ฑ๏ธ Drag and drop

The DragZone and DropZone components make drag and drop very simple to use. A drop zone wraps any element and receives a typed payload when a matching drag zone is released over it:

DropZone::<usize>::new(
    rect()
        .padding(16.0)
        .children(tasks.read().iter().map(|task| {
            DragZone::<usize>::new(task.id, Card(task.clone()))
                .show_while_dragging(false)
                .key(task.id)
                .into()
        })),
    move |task_id: usize| {
        // move task to this column
    },
)

See the kanban board example for the full version with animated Portal transitions when elements move between containers.

๐ŸŽž๏ธ Animations

The animation system has been reworked into its own freya-animation crate. You can animate numbers, colors, and sequences with configurable easing functions.

Animating a position with elastic easing:

fn app() -> impl IntoElement {
    let mut animation = use_animation(|_| {
        AnimNum::new(50., 550.)
            .function(Function::Elastic)
            .ease(Ease::Out)
            .time(1500)
    });

    let value = animation.read().value();

    rect()
        .child(
            rect()
                .position(Position::new_absolute().left(value).top(50.))
                .background(Color::BLUE)
                .width(Size::px(100.))
                .height(Size::px(100.)),
        )
        .child(
            rect()
                .horizontal()
                .center()
                .spacing(8.0)
                .child(Button::new().on_press(move |_| animation.start()).child("Start"))
                .child(Button::new().on_press(move |_| animation.reverse()).child("Reverse")),
        )
}

There is also use_animation_transition for smooth transitions between state changes:

fn app() -> impl IntoElement {
    let mut color = use_state(random_color);
    let animation =
        use_animation_transition(color, |from: Color, to| AnimColor::new(from, to).time(500));

    rect()
        .background(&*animation.read())
        .expanded()
        .center()
        .child(
            Button::new()
                .on_press(move |_| color.set(random_color()))
                .child("Random"),
        )
}

Animations can auto-start on creation, reverse on finish, or restart in a loop:

use_animation(|conf| {
    conf.on_creation(OnCreation::Run);
    AnimNum::new(0.8, 1.)
        .time(500)
        .function(Function::Expo)
        .ease(Ease::Out)
})

๐Ÿ–ผ๏ธ Canvas

For custom drawing, the canvas() element gives you direct access to the Skia canvas:

fn app() -> impl IntoElement {
    canvas(RenderCallback::new(|context| {
        let area = context.layout_node.visible_area();
        let center_x = area.center().x;
        let center_y = area.center().y;

        let mut paint = Paint::default();
        paint.set_anti_alias(true);
        paint.set_style(PaintStyle::Fill);
        paint.set_color(Color::BLUE);

        context.canvas.draw_circle((center_x, center_y), 50.0, &paint);
    }))
    .expanded()
}

๐Ÿ“ˆ Charts (Plotters)

The freya-plotters-backend crate wires the Plotters charting library into Freyaโ€™s Skia canvas. You draw any Plotters chart straight onto a canvas() element by building a PlotSkiaBackend from the render context:

canvas(RenderCallback::new(|ctx| {
    let backend = PlotSkiaBackend::new(
        ctx.canvas,
        ctx.font_collection,
        ctx.layout_node.area.size.to_i32().to_tuple(),
    )
    .into_drawing_area();

    backend.fill(&WHITE).unwrap();

    let mut chart = ChartBuilder::on(&backend)
        .caption("Chart", ("sans", 20))
        .build_cartesian_2d(-3.0..3.0, -3.0..3.0)
        .unwrap();

    chart
        .draw_series(LineSeries::new(
            (-100..100).map(|x| x as f64 / 40.0).map(|x| (x, x.sin())),
            &BLACK,
        ))
        .unwrap();
}))
.expanded()

Since it is just Plotters, 2D and 3D charts, surfaces, legends, and the rest of its API all work. See feature_plot_3d.rs for an interactive 3D surface that follows the cursor.

๐Ÿ“ท Camera

The freya-camera crate lets you capture and display webcam feeds. use_camera opens a device and CameraViewer renders the frames, with the same loading and error placeholders you get from ImageViewer:

fn app() -> impl IntoElement {
    let camera = use_camera(|| CameraConfig::new());

    CameraViewer::new(camera)
        .corner_radius(12.)
        .loading_placeholder(label().text("Opening camera...").color(Color::WHITE))
        .error_renderer(|err: CameraError| {
            label().color((255, 120, 120)).text(format!("Camera error: {err}")).into()
        })
}

You can enumerate the available devices with query() and select one by passing its CameraIndex to CameraConfig::new().device(index). Camera access is requested through freya::camera::init() at startup. See feature_camera.rs for a device picker.

๐Ÿ”„ Transforms

Elements support transforms effects:

rect()
    .expanded()
    .center()
    .offset_x(-50.)
    .offset_y(25.)
    .scale(0.5)
    .rotate(45.)
    .child(
        rect()
            .font_size(50.)
            .background((222, 231, 145))
            .child("hello!"),
    )

๐ŸŒ Internationalization (i18n)

The freya-i18n crate provides full internationalization using the Fluent translation format. Locales can be embedded at compile time or loaded from files at runtime:

fn app() -> impl IntoElement {
    let mut i18n = use_init_i18n(|| {
        I18nConfig::new(langid!("en-US"))
            .with_locale((langid!("en-US"), include_str!("./i18n/en-US.ftl")))
            .with_locale((langid!("es-ES"), PathBuf::from("./examples/i18n/es-ES.ftl")))
    });

    let change_to_english = move |_| i18n.set_language(langid!("en-US"));
    let change_to_spanish = move |_| i18n.set_language(langid!("es-ES"));

    rect()
        .expanded()
        .center()
        .spacing(6.)
        .child(
            rect()
                .spacing(6.)
                .horizontal()
                .child(Button::new().on_press(change_to_english).child("English"))
                .child(Button::new().on_press(change_to_spanish).child("Spanish")),
        )
        .child(t!("hello_world"))
        .child(t!("hello", name: "Freya!"))
}

The t!() macro translates keys with optional arguments. Language switching is instant and all subscribed components update automatically.

๐ŸŽจ Icons

The freya-icons crate provides access to only (for now) the SVGs of Lucide icon library:

use freya::icons;

rect()
    .horizontal()
    .main_align(Alignment::SpaceEvenly)
    .cross_align(Alignment::Center)
    .expanded()
    .child(
        svg(icons::lucide::antenna())
            .theme_accent_color()
            .width(Size::px(100.))
            .height(Size::px(100.)),
    )
    .child(
        svg(icons::lucide::shield())
            .color((120, 50, 255))
            .stroke_width(4.0)
            .width(Size::px(100.))
            .height(Size::px(100.)),
    )

๐Ÿ“ Code editor

The freya-code-editor crate provides a full syntax-highlighted code editor component with language support and customizable themes:

fn app() -> impl IntoElement {
    use_init_theme(dark_theme);
    let a11y_id = use_a11y();

    let custom_theme = use_state(|| EditorTheme {
        background: (20, 20, 20).into(),
        ..Default::default()
    });

    let editor = use_state(move || {
        let rope = Rope::from_str(&std::fs::read_to_string("./src/main.rs").unwrap());
        let mut editor = CodeEditorData::new(rope, LanguageId::Rust);
        editor.set_theme(SyntaxTheme {
            comment: (230, 230, 230).into(),
            ..Default::default()
        });
        editor.parse();
        editor.measure(14., "Jetbrains Mono");
        editor
    });

    CodeEditor::new(editor, a11y_id).theme(custom_theme)
}

Itโ€™s what powers my code editor Valin too.

๐ŸชŸ Docking

The new DockingArea component lets you build IDE-like layouts where tabs live inside panels, and panels can be split, resized, and rearranged by dragging tabs around, onto other panels or onto a panel edge to split it.

You own the layout data and you describe how to read and mutate that tree by implementing the DockingModel trait. DockingArea takes care of the rendering and drag-and-drop, calling back into your model when the user drops a tab.

// Your layout, a tree of panels and splits, keyed by your own ids.
struct Workspace {
    tree: Option<DockNode<TabId, PanelId>>,
}

impl DockingModel for Workspace {
    type TabId = TabId;
    type PanelId = PanelId;
    type DropValue = TabId;

    // The current tree, or `None` when there are no panels.
    fn root(&self) -> Option<&DockNode<TabId, PanelId>> {
        self.tree.as_ref()
    }

    // Called when a tab is dropped. Move it, or split a panel to make room.
    fn on_drop(&mut self, tab_id: TabId, target: DropTarget<PanelId>) -> bool {
        let Some(tree) = self.tree.as_mut() else {
            return false;
        };
        match target {
            DropTarget::Center(panel_id) | DropTarget::Tab { panel_id, .. } => {
                tree.panel_mut(&panel_id).map(|panel| panel.append_tab(tab_id));
                tree.remove_tab_except(&tab_id, Some(&panel_id));
            }
            DropTarget::Split { panel_id, side } => {
                let new_panel = DockPanel::new(next_panel_id(), vec![tab_id]);
                tree.split_panel(&panel_id, side, &new_panel);
            }
        }
        true
    }

    // Make `tab_id` the focused one inside `panel_id`.
    fn set_active(&mut self, panel_id: PanelId, tab_id: TabId) -> bool {
        tree_set_active(&mut self.tree, panel_id, tab_id)
    }
}

fn app() -> impl IntoElement {
    let workspace = use_state(|| Workspace {
        tree: Some(DockNode::Split {
            direction: Direction::Horizontal,
            children: vec![
                DockNode::Panel(DockPanel::new(0, vec![1, 2])),
                DockNode::Panel(DockPanel::new(1, vec![3])),
            ],
        }),
    });

    DockingArea::new(
        workspace.into_writable(),
        // The body of the active tab in a panel.
        |ctx: ContentContext<TabId, PanelId>| {
            rect().expanded().child(format!("Tab {:?}", ctx.tab_id)).into_element()
        },
        // A tab header.
        |ctx: TabContext<TabId>| {
            FloatingTab::new().child(format!("Tab {}", ctx.tab_id)).into_element()
        },
        // The drag preview that follows the cursor.
        |tab_id: TabId| FloatingTab::new().child(format!("Tab {tab_id}")).into_element(),
        // The bar that lays out the tab headers of a panel.
        |ctx: TabBarContext<PanelId>| {
            rect().horizontal().children(ctx.tab_children).into_element()
        },
    )
}

The DockNode tree comes with helpers for the common operations (panel_mut, append_tab, insert_tab, split_panel, remove_tab_except, close_empty_panels), so most of your DockingModel ends up being a thin layer over them. Check out the component_docking example for a complete workspace with new/close tab buttons and empty-panel collapsing.

๐Ÿงช Testing

Freya includes a headless testing runner that lets you write automated tests for your UI without opening a window. You can simulate clicks, keyboard input, and even make โ€œscreenshotsโ€:

use freya_testing::TestingRunner;

fn main() {
    let (mut test, state) = TestingRunner::new(
        app,
        (300., 300.).into(),
        |runner| runner.provide_root_context(|| State::create(0)),
        1.,
    );

    // Process layout and render
    test.sync_and_update();
    assert_eq!(*state.peek(), 0);

    // Simulate a click
    test.click_cursor((15., 15.));
    assert_eq!(*state.peek(), 1);

    // Save a screenshot
    test.render_to_file("./demo-1.png");
}

This also works for snapshot testing. You can render your UI at different states and compare the results:

let (mut runner, _) = TestingRunner::new(app, Size2D::new(500., 500.), |_| {}, 1.);
runner.render_to_file("./snapshot-before.png");
runner.click_cursor((270., 100.));
runner.render_to_file("./snapshot-after.png");

๐Ÿ“Š Built-in components

Freya ships with 45+ built-in components. Here are some of them:

let mut show_popup = use_state(|| false);

Popup::new()
    .on_close_request(move |_| show_popup.set(false))
    .maybe(show_popup(), |popup| {
        popup
            .child(PopupTitle::new("Title".to_string()))
            .child(PopupContent::new().child("Hello, World!"))
            .child(
                PopupButtons::new().child(
                    Button::new()
                        .on_press(move |_| show_popup.set(false))
                        .expanded()
                        .filled()
                        .child("Accept"),
                ),
            )
    })
    

Context menu

Easily open floating menus from anywhere in your app.

Make sure that you call ContextMenuViewer::new() somewhere else in your app tree, thatโ€™s the slot that will be used to render the context menus.

fn context_menu() -> Menu {
    Menu::new()
        .child(
            SubMenu::new()
                .child(MenuButton::new().child("Option 1"))
                .child(MenuButton::new().child("Option 2"))
                .label("Options"),
        )
        .child(MenuButton::new().child("Close").on_press(move |_| ContextMenu::close()))
}

// Open from any press event
Button::new().on_press(move |e: Event<PressEventData>| {
    ContextMenu::open_from_event(&e, context_menu())
})

Table

Set of composable components to build Tables.

Table::new()
    .column_widths([Size::flex(4.), Size::flex(3.), Size::flex(1.)])
    .child(
        TableHead::new().child(
            TableRow::new().children(columns.into_iter().map(|(text, order_by)| {
                TableCell::new()
                    .order_direction(if *order.read() == order_by {
                        Some(*order_direction.read())
                    } else {
                        None
                    })
                    .on_press(move |_| on_column_head_click(&order_by))
                    .child(text.to_string())
                    .into()
            })),
        ),
    )
    .child(TableBody::new().child(ScrollView::new().children(
        data.iter().map(|row| {
            TableRow::new()
                .children(row.iter().map(|cell| TableCell::new().child(cell.clone()).into()))
                .into()
        }),
    )))

Color picker

A component to let users select colors.

let mut color = use_state(|| Color::from_rgb(205, 86, 86));

ColorPicker::new(move |c| color.set(c)).value(color())

Markdown viewer

A super simple viewer of markdown content.

MarkdownViewer::new("# Hello\n\nThis is **bold** and *italic* with `inline code`.")

Supports headings, lists, tables, code blocks, blockquotes, images, links, and horizontal rules.

Image viewer

ImageViewer handles async loading, caching, and error states for local, embedded, and remote images:

ImageViewer::new("https://example.com/photo.jpg")
    .width(Size::percent(50.))
    .a11y_alt("A photo")
    .error_renderer(|err: String| {
        label().color((255, 120, 120)).text(format!("Failed: {err}")).into()
    })

It accepts a URL, a PathBuf, or embedded bytes (("id", include_bytes!("./logo.png"))).

Component variants

Most built-in components share a common set of style and layout variant methods, so once you learn them for one component they carry over to the others. For example, Button:

Button::new().child("Default")
Button::new().filled().child("Filled")
Button::new().outline().child("Outline")
Button::new().flat().child("Flat")
Button::new().compact().child("Compact")
Button::new().expanded().child("Expanded")
Button::new().ripple().child("With ripple")
Button::new().rounded_full().child("Pill")
Button::new().enabled(false).child("Disabled")

And Input exposes the same family of modifiers:

Input::new(text).placeholder("Default")
Input::new(text).placeholder("Filled").filled()
Input::new(text).placeholder("Flat").flat()
Input::new(text).placeholder("Compact").compact()
Input::new(text).placeholder("Expanded").expanded()
Input::new(text).placeholder("Disabled").enabled(false)

Variants compose, so Input::new(text).compact().filled() or Button::new().outline().expanded() work as you would expect.

On top of the variants, every themed component now exposes each of its theme fields as a regular builder method. Instead of constructing a ButtonColorsThemePartial and passing it through .theme_colors(...), you can just call the field directly on the component:

Button::new()
    .background((15, 163, 242))
    .hover_background((10, 130, 200))
    .color(Color::WHITE)
    .corner_radius(12.)
    .padding(Gaps::all(16.))
    .child("Custom")

These methods are generated from the componentโ€™s theme definition, so Input, Switch, Slider, and the rest get the same treatment for their own theme fields. You can still pass a full ThemePartial via .theme_colors(...) / .theme_layout(...) when you want to share an override across many instances, but for one-off tweaks the inline methods are usually enough.

And more

The full list includes: Accordion, Attached, Calendar, Card, Checkbox, Chip, CircularLoader, ColorPicker, ContextMenu, CursorArea, DraggableCanvas, DragZone, DropZone, FloatingTab, GifViewer, ImageViewer, Input, Link, MarkdownViewer, Menu, OverflowedContent, Popup, Portal, ProgressBar, RadioItem, ResizableContainer, ScrollView, SegmentedButton, Select, SelectableText, SideBarItem, Skeleton, Slider, Switch, Table, Tile, Tooltip, VirtualScrollView.

All components follow the same builder pattern and support theming.

๐ŸŒŠ Material Design

The freya-material-design crate adds Material Design flavored extensions on top of the base components, for now only ripple effects. Calling .ripple() on a supported component (Button, Tile, SideBarItem, MenuItem, FloatingTab) will make them show a ripple effect on press.

use freya::material_design::*;

rect()
    .center()
    .spacing(12.)
    // A button with a ripple on press
    .child(Button::new().ripple().child("Click me!"))
    // Any element can ripple by wrapping it
    .child(
        Ripple::new().child(
            rect()
                .width(Size::px(100.))
                .height(Size::px(70.))
                .center()
                .background((70, 40, 120))
                .color(Color::WHITE)
                .child("Click me!"),
        ),
    )

These extensions live behind the material-design feature flag, so you only pull them in when you want them.

๐Ÿ“Š Performance plugin

The performance overlay drops a small panel on top of your app with live frame info. Enabled by default on debug builds. Toggle it with Ctrl+Shift+P (or Cmd+Shift+P on macOS):

launch(
    LaunchConfig::new()
        .with_plugin(PerformanceOverlayPlugin::new())
        .with_window(WindowConfig::new(app))
)

It shows:

  • FPS, with a tiny history graph.
  • Timings for rendering, presenting, layout, tree updates and accessibility updates.
  • How many tree and layout nodes exist.
  • The current scale factor and animation clock.
  • What renderer backend is the app using.

๐Ÿ› ๏ธ Devtools

Freya ships with a devtools application that connects to your running app to inspect and debug it in real time. With it you can browse the node tree, inspect element styles, layout, and text styles, highlight elements on hover, and control the animation speed.

The devtools server is gated behind the devtools feature flag, and the recommended setup is to forward it from a feature in your own crate so it never leaks into release builds:

# In your Cargo.toml
[features]
devtools = ["freya/devtools"]

No code changes are needed: when the feature is active, launch automatically registers the devtools server plugin. The UI itself is a separate standalone app, freya-devtools-app, which you install once and run alongside your app:

cargo install freya-devtools-app

# then, with your app running with `--features devtools`:
freya-devtools-app

It connects automatically, retrying until your app is up. Only one devtools-enabled app can run at a time, since the server listens on a fixed local port.

โ™ฟ Accessibility

Freya has built-in accessibility support with focus management and ARIA-like roles:

let a11y_id = use_a11y();
let focus = use_focus(a11y_id);

rect()
    .a11y_id(a11y_id)
    .a11y_role(AccessibilityRole::Button)
    .a11y_auto_focus(true)
    .background(if focus.read().is_focused() {
        (100, 149, 237)
    } else {
        (200, 200, 200)
    })
    .on_press(move |_| {
        a11y_id.request_focus();
    })

The Platform API exposes the current navigation mode so you can style focus rings differently for keyboard vs pointer navigation:

let platform = Platform::get();
let keyboard_nav = *platform.navigation_mode.read() == NavigationMode::Keyboard;

๐Ÿ–ฅ๏ธ Rendering

  • Windows defaults to OpenGL.
  • Linux defaults to Vulkan.
  • macOS defaults to Metal.
  • A software renderer is available as a fallback.

๐Ÿ“ฑ Android support (experimental)

Freya 0.4 has experimental Android support via the new freya-android crate.

Look in the ./examples/android example.

๐Ÿ“ฆ Feature flags

Freya uses feature flags to keep the default build lean. Enable only what you need:

  • router for client-side routing
  • i18n for internationalization
  • radio for channel-based global state
  • icons for the Lucide icon library
  • webview for embedded web views
  • terminal for terminal emulation
  • code-editor for syntax-highlighted editor
  • camera for camera capture
  • plot for charting with Plotters
  • tray for system tray support
  • titlebar for custom window titlebars
  • material-design for Material Design components and ripple effects
  • calendar for date picker
  • markdown for markdown rendering
  • gif for animated GIFs
  • query for the query/caching system
  • sdk for the Freya SDK utilities
  • remote-asset for loading assets from URLs
  • devtools for the development inspector
  • performance for the performance overlay

๐Ÿ™ Thanks

Thank you to everyone who contributed to this release, whether through code, bug reports, or feedback. Special thanks to SparkyTD for the Android support work.

If you want to support Freyaโ€™s development, you can sponsor me on GitHub.