Agent skill

Freya

by marc2332 in marc2332/freya

Freya Rust GUI framework best practices, patterns, and conventions.

MITAuto-check passedMobile

Install Freya

skills CLI
$ npx skills add marc2332/freya --skill freya -a claude-code

Project install by default; add -g for ~/.claude/skills/.

GitHub CLI
$ gh skill install marc2332/freya freya --agent claude-code

Project scope by default; add --scope user for a personal install. Needs GitHub CLI 2.90.0 or later (public preview).

Manual copy
$ git clone --depth 1 https://github.com/marc2332/freya.git skills-src && mkdir -p .claude/skills && cp -r skills-src/plugins/freya/skills/freya .claude/skills/freya && rm -rf skills-src

Use ~/.claude/skills/ instead of .claude/skills for a personal install. The folder must contain SKILL.md.

Claude Code skills documentation · loads skills from .claude/skills/

Facts

Skill name
freya
GitHub stars
3.2k
Token cost
~9.1k tokens
SKILL.md length
2,660 words
Files
1
Skills in repo
1
Repo updated
First seen
Licence
MIT

At a glance

Freya Rust GUI framework best practices, patterns, and conventions.

  • Works in 6 steps: Only call hooks at the top level of… → Hooks must be called in the same order… → Never call hooks inside event handlers -… → …
  • Writing Freya components
  • SKILL.md covers Components, Elements, Element Builder Pattern and Events, plus 6 more sections
  • Calls node; reaches freyaui.dev

What it does

Freya is an agent skill from marc2332/freya. Freya Rust GUI framework best practices, patterns, and conventions. Use when writing Freya components, hooks, elements, or working on a Freya project.

Its SKILL.md is about 9.1k tokens, which your agent loads only when the skill is triggered. It is a single SKILL.md file with no bundled scripts.

It sits in Mobile. It works with Rust and Android. The repository describes itself as: Cross-platform native GUI library for 🦀 Rust. The licence is MIT.

When your agent uses it

  • Writing Freya components
  • Working on a Freya project

Example prompts

  • “/freya”

Workflow steps

6 steps, taken from the first numbered list in SKILL.md.

  1. Only call hooks at the top level of render - never inside conditionals, loops, or closures.
  2. Hooks must be called in the same order on every render.
  3. Never call hooks inside event handlers - call them at the top of render and capture the values in move closures instead.
  4. Never call hooks inside loops - the number of hook calls must be constant across renders.
  5. Never call hooks outside of components - hooks only work inside a render method or a function component.
  6. Never call hooks inside async tasks - spawn callbacks are async and cannot call hooks; capture state before spawning. Some hooks have…

What it can do on your machine

Read from SKILL.md and the folder at commit 4c52087. It shows what the files ask for, not the result of running them.

  • Tool permissions

    Pre-approves nothing: there is no allowed-tools line, so your agent's usual permission prompts apply.

    From allowed-tools in the SKILL.md frontmatter.

  • Runs code

    Shell commands in SKILL.md call:

    • node

    From the folder's file list and the shell code blocks in SKILL.md.

  • Network

    Hosts in commands or code, which the agent is likely to contact:

    • freyaui.dev

    Also links to:

    • projectfluent.org

    From URLs in SKILL.md, links to its own repository left out.

  • Credentials

    Names no API keys, tokens, secrets or passwords.

    From names ending in _API_KEY, _TOKEN, _SECRET, _KEY or _PASSWORD in SKILL.md.

Context cost

Freya loads about 9.1k tokens when it runs. Until then it costs about 39 tokens; SKILL.md has 2,660 words of instructions outside code blocks.

Always · name and description, kept in context so the agent knows when to use it
~39
When it runs · the whole SKILL.md, loaded when a task matches
~9.1k

Estimates: characters ÷ 4, the usual rule of thumb; real counts depend on the model's tokenizer. Scripts and assets cost tokens only if the agent reads them.

Safety

Auto-check passed

The automated check found no risky patterns in SKILL.md.

Automated static check — not a guarantee. Review scripts before installing. It scans the text of SKILL.md for risky patterns (piping downloads into a shell, reading credential files, hidden Unicode, destructive commands); files beside SKILL.md are not scanned.

SKILL.md

The full file from marc2332/freya at commit 4c52087, republished under its MIT licence (© marc2332). 2,660 words, ~9,094 tokens.

Download SKILL.mdSave it as .claude/skills/freya/SKILL.md (or your agent's skills folder).
name
freya
description
Freya Rust GUI framework best practices, patterns, and conventions. Use when writing Freya components, hooks, elements, or working on a Freya project.
user-invocable
true

Freya Best Practices

Freya is a cross-platform, native, declarative GUI library for Rust.

General rules:

  • Do not write unnecessary comments in code.
  • Do not use em dashes.
  • Be simple, elegant, and concise.

Start by asking the user what they would like to do:

  • Lint and improve existing code
  • Refactor existing code
  • Add a new feature

Components

Struct Components (for stateful UI)
rust
#[derive(PartialEq)]
struct Counter {
    initial: i32,
}

impl Component for Counter {
    fn render(&self) -> impl IntoElement {
        let mut count = use_state(|| self.initial);
        label()
            .on_press(move |_| *count.write() += 1)
            .text(format!("Count: {}", count.read()))
    }
}
  • #[derive(PartialEq)] is required - Freya uses it to skip re-rendering unchanged subtrees.
  • Implement KeyExt and ChildrenExt when the component can be keyed or accept children.
ComponentOwned (when render needs to own self)

Component::render takes &self, so moving fields into closures forces let foo = self.foo.clone(); boilerplate. ComponentOwned::render takes self by value (the framework clones it for you), letting you move self directly. Requires #[derive(Clone)]. Reach for it only when you'd otherwise clone self (or several of its fields) inside render.

rust
#[derive(PartialEq, Clone)]
struct Item { state: State<Vec<i32>>, i: usize }

impl ComponentOwned for Item {
    fn render(mut self) -> impl IntoElement {
        Button::new()
            .on_press(move |_| { self.state.write().remove(self.i); })
            .child("Remove")
    }
}
Function Components (app root only)

The app root is a plain function. Hooks like use_init_theme, use_init_radio_station, use_provide_context belong here.

rust
fn app() -> impl IntoElement {
    rect().child("Hello, World!")
}

To pass data from main into the root, use the App trait:

rust
struct MyApp { number: u8 }

impl App for MyApp {
    fn render(&self) -> impl IntoElement {
        label().text(self.number.to_string())
    }
}
Utility Functions (stateless, no hooks)
rust
fn colored_label(color: Color, text: &str) -> impl IntoElement {
    label().color(color).text(text.to_string())
}

Reusable UI that uses hooks or props MUST be a Component (struct + impl Component). Plain functions are only for the app root and stateless helpers. Functions with hooks won't benefit from diffing/memoization and can't be keyed or accept reactive props cleanly.

Elements

Built-in element constructors:

  • rect() - layout primitive (direction, alignment, sizing, background, borders, corners, shadows, padding, scroll).
  • label() - single-line text.
  • paragraph() - multi-line / rich text via .text_span(...) children; also the target for use_editable.
  • image(handle) - raster image; handle from static_bytes(...), dynamic_bytes(...), or asset loaders.

&str / String implement Into<Label>, so prefer rect().child("Hi") over rect().child(label().text("Hi")).

Element Builder Pattern

Elements use a fluent builder API. Never store an element in a variable to modify it later - chain all methods directly or use .maybe / .map.

rust
// Good
rect()
    .background((255, 0, 0))
    .width(Size::fill())
    .height(Size::px(100.))
    .center()       // centers children both axes
    .expanded()     // fills available space in parent's main axis
    .horizontal()   // sets layout direction to horizontal
    .maybe(is_active, |el| el.child("Active"))
    .map(some_value, |el, v| el.child(v.to_string()))

// Bad - storing to modify later
let mut element = rect();

Common layout shorthands: .center() centers children on both axes; .expanded() makes the element fill all remaining space along the parent's main axis (equivalent to flex: 1 in CSS); .horizontal() and .vertical() set the layout direction (prefer them over .direction(Direction::Horizontal/Vertical)).

Conditional and Dynamic Rendering
rust
rect()
    .maybe(show_badge, |el| el.child("New"))          // bool condition
    .map(large_size, |el, size| el.height(size))      // Option<T>, passes value
    .maybe_child(optional_element)                    // Option<impl IntoElement>
  • .maybe(bool, |el| el) - applies the callback when the condition is true
  • .map(Option<T>, |el, val| el) - applies the callback when the Option is Some, passing the inner value
  • .maybe_child(Option<impl IntoElement>) - appends a child only when Some

Name the element argument el in .maybe / .map callbacks (not r, rect, e, etc.).

Prefer one outer .maybe / .map over several consecutive .maybe_child calls gated on the same condition - it keeps the gating in one place and avoids re-evaluating the same predicate.

rust
// Good, single .maybe wraps all conditional children
rect()
    .maybe(show, |el| {
        el.child(Title::new("Hi"))
            .child(Content::new().child("Hello"))
            .child(Footer::new())
    })

// Bad, same predicate repeated per child
rect()
    .maybe_child(show.then(|| Title::new("Hi")))
    .maybe_child(show.then(|| Content::new().child("Hello")))
    .maybe_child(show.then(|| Footer::new()))

Events

Attach handlers via builder methods on any element. Handlers receive Event<T>; use move closures to capture state.

rust
rect()
    .on_press(move |_| { /* left click, tap, or Enter/Space when focused */ })
    .on_key_down(move |e: Event<KeyboardEventData>| { /* only while focused */ })
    .on_wheel(move |e| { /* scroll delta */ })
    .on_pointer_enter(move |_| { /* hover begin (mouse or touch) */ })

Catalog (all prefixed on_):

  • Press: press (left/tap/Enter/Space), all_press (any mouse button), secondary_down (right-click).
  • Mouse: mouse_up, mouse_down, mouse_move.
  • Pointer (mouse + touch unified): pointer_press, pointer_down, pointer_move, pointer_enter, pointer_leave, pointer_over, pointer_out.
  • Keyboard (require focus): key_down, key_up.
  • Wheel: wheel.
  • Touch: touch_start, touch_end, touch_move, touch_cancel.
  • File drop: file_drop.
  • Layout: sized (measured size changed).
  • Global (no hit-test; use sparingly): global_pointer_up, global_pointer_down, global_pointer_move, global_key_down, global_key_up, global_file_hover, global_file_hover_cancelled.
  • Capture (run before regular handlers): capture_global_pointer_up, capture_global_pointer_move.

Prefer on_press over raw mouse/pointer events for interactive elements: it covers click, tap, and keyboard activation, so accessibility comes free. Use on_mouse_* / on_pointer_* only when you need pointer-specific behavior (drag handles, canvas tools).

Event<T> has two cancellation methods, plus .map(...) / .try_map(...) to transform inner data:

  • .stop_propagation(): don't bubble this event to ancestor handlers. No effect on events that don't bubble (move/enter/leave, capture, global).
  • .prevent_default(): don't fire the follow-up events this one triggers (e.g. in on_mouse_up, suppresses the on_pointer_press and on_global_pointer_up that would follow).
Callback props on custom components

Use EventHandler<T> for callback props; closures convert via .into().

rust
#[derive(PartialEq)]
struct Confirm { on_accept: EventHandler<()> }

impl Component for Confirm {
    fn render(&self) -> impl IntoElement {
        let on_accept = self.on_accept;
        Button::new().on_press(move |_| on_accept.call(())).child("OK")
    }
}

Confirm { on_accept: (move |()| println!("yes")).into() }

EventHandler<T> is Copy; capture directly in move closures.

Focus and Accessibility

Focusable elements need a stable AccessibilityId from use_a11y() (one per focusable node), attached via .a11y_id(...) and .a11y_focusable(true). Track focus state with use_focus(id).

rust
#[derive(PartialEq)]
struct FocusableBox;

impl Component for FocusableBox {
    fn render(&self) -> impl IntoElement {
        let a11y_id = use_a11y();
        let focus = use_focus(a11y_id);

        rect()
            .a11y_id(a11y_id)
            .a11y_focusable(true)
            .a11y_role(AccessibilityRole::Button)
            .on_press(move |_| println!("activated"))
            .maybe(focus() == Focus::Keyboard, |el| {
                el.border(Border::new().fill(Color::BLUE).width(2.))
            })
            .child("Click or Tab to me")
    }
}

Focus variants:

  • Focus::Not - not focused.
  • Focus::Pointer - focused by mouse/touch (no focus ring needed).
  • Focus::Keyboard - focused via Tab (render a focus ring).
  • focus.is_focused() matches Pointer or Keyboard.

on_press already fires on Enter/Space when focused, so keyboard activation is free. For raw key handling, KeyboardEventExt::is_press_event(&event) detects the OS activation gesture (Enter/Space; Ctrl+Alt+Space on macOS with VoiceOver).

Other a11y builders: .a11y_role(...), .a11y_alt("description"), .a11y_auto_focus(true), .a11y_member_of(other_id), .a11y_builder(|node| { /* raw accesskit::Node */ }).

Read the focused id globally via Platform::get().focused_accessibility_id.

Hooks

Hooks are prefixed with use_ (e.g. use_state, use_animation). Follow these rules:

  1. Only call hooks at the top level of render - never inside conditionals, loops, or closures.
  2. Hooks must be called in the same order on every render.
  3. Never call hooks inside event handlers - call them at the top of render and capture the values in move closures instead.
  4. Never call hooks inside loops - the number of hook calls must be constant across renders.
  5. Never call hooks outside of components - hooks only work inside a render method or a function component.
  6. Never call hooks inside async tasks - spawn callbacks are async and cannot call hooks; capture state before spawning. Some hooks have non-hook counterparts that are safe to call in async contexts (e.g. use_consume → consume_context()).

Capture hook values in move closures for event handlers:

rust
let mut state = use_state(|| false);
let on_click = move |_| state.set(true); // capture, not call inside handler
rect().on_press(on_click)

State Management

Local State
rust
let mut count = use_state(|| 0);
*count.write() += 1;          // write
let n = *count.read();        // read
count.set(5);                 // convenience setter
count.set_if_modified(5);     // only writes (and notifies) if the new value differs

use_state returns a Copy type (State<T>). No .clone() needed when passing it around.

Avoid drop() on guards from .read()/.write(); guards release on scope exit. Prefer a smaller scope ({ let v = state.read(); ... }) or copying the value out (let n = *count.read();). Only use explicit drop(guard) when you must release a borrow before re-borrowing in the same scope.

Prefer set_if_modified over set when the new value may equal the current (syncing external/derived values, handlers that may fire unchanged). It skips the write and avoids waking subscribers. Requires T: PartialEq. set_if_modified_and_then(value, || { ... }) runs a callback only on actual change.

Pass local state to child components:

rust
#[derive(PartialEq)]
struct Child(State<i32>);
Global State - Freya Radio

Use Freya Radio for large or deeply nested app state where you need surgical, fine-grained updates - only the components subscribed to a specific channel re-render when that channel changes. This makes it well-suited for complex UIs (e.g. a tab system where each tab has independent state, or a big data model where different parts of the UI subscribe to different slices).

Define your state and a channel enum that maps to the parts of the state that can change independently:

rust
#[derive(Default, Clone)]
struct AppState {
    count: i32,
    name: String,
}

#[derive(PartialEq, Eq, Clone, Debug, Copy, Hash)]
enum AppChannel {
    Count,
    Name,
}

impl RadioChannel<AppState> for AppChannel {}

Initialize once in the root component, then subscribe from any descendant:

rust
// Root
use_init_radio_station::<AppState, AppChannel>(AppState::default);

// Any component - only re-renders when AppChannel::Count changes
let mut radio = use_radio(AppChannel::Count);
radio.read().count;
radio.write().count += 1;

For channels where a write to one should also notify subscribers of another, override derive_channel:

rust
impl RadioChannel<AppState> for AppChannel {
    fn derive_channel(self, _state: &AppState) -> Vec<Self> {
        match self {
            // Writing to Count also notifies Name subscribers
            AppChannel::Count => vec![self, AppChannel::Name],
            AppChannel::Name => vec![self],
        }
    }
}

For complex state transitions, implement the reducer pattern with DataReducer:

rust
impl DataReducer for AppState {
    type Channel = AppChannel;
    type Action = AppAction;

    fn reduce(&mut self, action: AppAction) -> ChannelSelection<AppChannel> {
        match action {
            AppAction::Increment => { self.count += 1; }
            AppAction::SetName(n) => { self.name = n; }
        }
        ChannelSelection::Current
    }
}

// Then in a component:
radio.apply(AppAction::Increment);
Readable / Writable (type-erased abstractions)

Readable<T> and Writable<T> are type-erased wrappers over any reactive value of type T. Use them as component props so the component works regardless of where the state lives: local State<T>, a Memo<T>, a radio slice, or a plain owned value. Both are PartialEq (always equal) and Clone, usable directly as component fields.

rust
#[derive(PartialEq)]
struct NameInput { name: Writable<String> }

impl Component for NameInput {
    fn render(&self) -> impl IntoElement {
        Input::new(self.name.clone()) // Input is two-way bound to the Writable
    }
}

// Caller side: any source converts via `into_writable()` / `into_readable()`
NameInput { name: local_name.into_writable() }            // from State<String>
NameInput { name: name_slice.into_writable() }            // from a RadioSliceMut

Conversions:

  • State<T> → Writable<T> / Readable<T> via IntoWritable / IntoReadable.
  • Memo<T> → Readable<T> via IntoReadable.
  • RadioSlice → Readable<T>; RadioSliceMut → Readable<T> or Writable<T>.
  • Writable<T> → Readable<T> via From (downgrade write access).
  • Plain owned T → Readable<T> via From (non-reactive; tests, defaults).

API surface:

  • Readable<T>: read() (subscribes), peek() (no subscription).
  • Writable<T>: read(), peek(), write(), plus WritableUtils helpers (set, set_if_modified, with_mut, ...). Subscriptions and notifications route to the original source.

Prefer Readable<T> for read-only consumers; use Writable<T> only when the component must mutate.

Context API

Use context to make a value available to any descendant component without threading it through every prop. Prefer this over static variables, thread_local!, or global singletons - context is scoped to the component tree and plays well with Freya's reactivity.

rust
// Provider: stores the value and makes it available to all descendants
fn app() -> impl IntoElement {
    use_provide_context(|| AppConfig { theme: Theme::Dark });
    rect().child(DeepChild {})
}

// Consumer: retrieve by type, walks up the tree until found
#[derive(PartialEq)]
struct DeepChild;
impl Component for DeepChild {
    fn render(&self) -> impl IntoElement {
        let config = use_consume::<AppConfig>();
        format!("Theme: {:?}", config.theme)
    }
}

Use use_try_consume::<T>() when the context may not be present. If context is not found, use_consume panics.

Context values are identified by type, so each distinct type gets its own slot. Providing the same type again in a deeper component shadows the ancestor's value for that subtree.

Context is the right tool for dependency injection (e.g. passing a DB client, config, or theme down the tree). For reactive shared state use Freya Radio; for passing state between a parent and immediate children, plain props or State<T> are simpler.

Choosing state type
  • use_state - component-local state
  • Context API - dependency injection and non-reactive shared values across the tree; prefer over statics
  • Freya Radio - large/nested state, surgical per-channel updates, multi-window
  • Readable/Writable - reusable components that don't care about backing storage

Derived State and Side Effects

For simple derived values, compute them directly in render - no hook needed:

rust
let doubled = *count.read() * 2;

For expensive computations that should only re-run when their dependencies change, use use_memo. It subscribes to any State read inside the callback and caches the result:

rust
let expensive = use_memo(move || {
    let n = *count.read(); // subscribed - reruns when count changes
    compute_something(n)
});
let value = expensive.read();

For side effects that should re-run when state changes (e.g. logging, triggering external systems), use use_side_effect. Do not use it to sync one state into another - derive values directly or use use_memo instead:

rust
use_side_effect(move || {
    let value = *count.read(); // subscribed
    println!("count changed: {value}");
});

Async

Spawning tasks

Use Freya's spawn() (not tokio::spawn) for async work that updates the UI. Tasks spawned with spawn() are tied to Freya's reactivity system and can safely write to component state:

rust
let mut data = use_state(|| None);

use_hook(move || {
    spawn(async move {
        let result = fetch_something().await;
        data.set(Some(result));
    });
});

use_hook runs once on mount, making it the right place for one-shot side effects. spawn returns a TaskHandle you can cancel if needed.

Async functions in components

Components and hooks are synchronous - you cannot await inside render. Prefer use_future for typical async work (see below). Only reach for use_hook + spawn when you need fine-grained control over the task lifecycle:

rust
// Only when you need manual control
use_hook(move || {
    spawn(async move {
        let s = some_async_fn().await;
        result.set(s);
    });
});
use_future

use_future wraps this pattern: it starts an async task on mount and exposes its state as FutureState<D> (Pending, Loading, Fulfilled(D)):

rust
let task = use_future(|| async {
    fetch_user(42).await
});

match &*task.state() {
    FutureState::Pending | FutureState::Loading => "Loading...",
    FutureState::Fulfilled(user) => user.name.as_str(),
}

Call task.start() to restart and task.cancel() to stop it.

freya-query (cached async data)

For data that should be cached, deduplicated, and automatically refetched, use freya-query (features = ["query"]):

rust
// Define the query
#[derive(Clone, PartialEq, Hash, Eq)]
struct FetchUser;

impl QueryCapability for FetchUser {
    type Ok = String;
    type Err = String;
    type Keys = u32;

    async fn run(&self, user_id: &u32) -> Result<String, String> {
        Ok(format!("User {user_id}"))
    }
}

// Use it in a component
impl Component for UserProfile {
    fn render(&self) -> impl IntoElement {
        let query = use_query(Query::new(self.0, FetchUser));

        match &*query.read().state() {
            QueryStateData::Pending => "Loading...",
            QueryStateData::Settled { res, .. } => res.as_deref().unwrap_or("Error"),
            QueryStateData::Loading { .. } => "Refreshing...",
        }
    }
}

Multiple components using the same (capability, keys) pair share one cache entry. Invalidate with query.invalidate() or QueriesStorage::<FetchUser>::invalidate_all().await.

For write operations, use use_mutation + MutationCapability. The on_settled callback is the right place to invalidate related queries after a mutation.

Prefer freya-query over manual use_future + state when you need caching, background refetch, or deduplication.

Show full SKILL.md (1,037 more words)Show less
Tokio integration

Freya has its own async runtime. To use Tokio-ecosystem crates (reqwest, sqlx, etc.), enter a Tokio runtime context in main before launching:

rust
fn main() {
    let rt = tokio::runtime::Builder::new_multi_thread().enable_all().build().unwrap();
    let _guard = rt.enter(); // keep alive for the whole program

    launch(LaunchConfig::new().with_window(WindowConfig::new(app)))
}

Use Freya's spawn() for UI updates. tokio::spawn runs on the Tokio runtime and cannot update component state.

Theming

Use the theming system to centralize component styling. Prefer it over hand-rolled structs of Color/size constants or scattered hardcoded values.

A Theme bundles a ColorsSheet (the app palette) and per-component *ThemePreference entries indexed by string key. Components read their theme via get_theme! and resolve color references against the active ColorsSheet.

Provide a theme

Initialize a theme in the root component. The returned State<Theme> is reactive - writing to it switches the theme app-wide:

rust
fn app() -> impl IntoElement {
    let mut theme = use_init_theme(light_theme); // or dark_theme, or your own
    rect()
        .theme_background()
        .theme_color()
        .expanded()
        .center()
        .child(Button::new().on_press(move |_| theme.set(dark_theme())).child("Dark"))
}

Use use_init_root_theme to register at the root scope. To follow the OS preference, convert Platform::get().preferred_theme via the FromPreference::to_theme extension.

Theme extensions

Built-in elements and components expose helpers that read the active theme - prefer these over hardcoded colors:

  • rect().theme_background(), rect().theme_color()
  • label().theme_color(), paragraph().theme_color()
  • SvgViewer::new(...).theme_color() / .theme_accent_color() / .theme_fill() / .theme_stroke() / .theme_accent_fill() / .theme_accent_stroke()
Custom themes

Start from light_theme() / dark_theme() and override what you need. Use LIGHT_COLORS / DARK_COLORS as base palettes with struct update syntax:

rust
fn brand_theme() -> Theme {
    let mut theme = dark_theme();
    theme.name = "brand";
    theme.colors = ColorsSheet {
        primary: Color::from_rgb(37, 52, 63),
        secondary: Color::from_rgb(255, 155, 81),
        tertiary: Color::from_rgb(81, 155, 255),
        ..DARK_COLORS
    };
    theme
}

ColorsSheet covers brand (primary/secondary/tertiary), status (success/warning/error/info), surfaces (background, surface_primary/secondary/tertiary, surface_inverse[_secondary|_tertiary]), borders (border, border_focus, border_disabled), text (text_primary/secondary/placeholder/inverse/highlight), interaction states (hover, focus, active, disabled), and utility (overlay, shadow).

Defining a theme for your component

Use define_theme! to generate the theme types for a component. This replaces hand-rolled "props struct of colors and sizes" patterns:

rust
define_theme! {
    %[component]
    pub StatusBadge {
        %[fields]
        background: Color,
        color: Color,
        corner_radius: CornerRadius,
        padding: Gaps,
    }
}

#[derive(PartialEq)]
struct StatusBadge {
    theme: Option<StatusBadgeThemePartial>,
}

impl Component for StatusBadge {
    fn render(&self) -> impl IntoElement {
        let StatusBadgeTheme { background, color, corner_radius, padding } =
            get_theme!(&self.theme, StatusBadgeThemePreference, "status_badge");

        rect()
            .background(background)
            .corner_radius(corner_radius)
            .padding(padding)
            .child(label().text("Active").color(color).font_size(12.))
    }
}

The macro generates three structs:

  • StatusBadgeThemePartial - Option<Preference<T>> per field, used for per-instance overrides.
  • StatusBadgeThemePreference - Preference<T> per field, registered in the Theme as the default.
  • StatusBadgeTheme - resolved concrete values, returned by get_theme!.

get_theme!(&self.theme, StatusBadgeThemePreference, "status_badge") looks up the registered preference, applies any ThemePartial override on the instance, and resolves it against the active ColorsSheet.

Register the default preference on your custom theme:

rust
theme.set(
    "status_badge",
    StatusBadgeThemePreference {
        background: Preference::Reference("secondary"),    // resolves from ColorsSheet
        color: Preference::Reference("text_inverse"),
        corner_radius: CornerRadius::new_all(99.).into(),  // Specific via Into
        padding: Gaps::new(4., 10., 4., 10.).into(),
    },
);

Preference::Reference("...") looks up the named color in the active ColorsSheet so the component automatically follows theme switches. Only Color fields support references - Size, Gaps, CornerRadius, f32, and Duration must use Preference::Specific(v) (or v.into()).

The macro also generates a StatusBadgeThemePartialExt trait implemented on the component, giving callers per-field builder methods:

rust
StatusBadge::new()
    .background(Color::from_rgb(123, 123, 123))   // from the generated Ext trait
    .corner_radius(CornerRadius::new_all(4.))

For components that store the partial under a non-default field name (e.g. theme_colors alongside theme_layout), pass for = ...; theme_field = ...; to point the generated builder at the right field. Examples: Button, Card, Switch, Input in crates/freya-components/.

When to define a theme
  • The component is reusable and could be styled differently across apps or theme switches: use define_theme!.
  • You currently have a struct of Color/size constants that callers tweak: replace it with a theme.
  • One-off internal helpers with no styling variation: plain values are fine, no theme needed.

Keying

Use .key(id) on elements in dynamic lists to ensure correct reconciliation on reorders:

rust
VirtualScrollView::new(|item, _| {
    rect()
        .key(item.index)
        .child(format!("Item {}", item.index))
        .into()
})
.length(items.len())

Missing .key() in dynamic lists causes element misidentification during reorders.

A custom component can expose the same .key method by implementing KeyExt over a stored DiffKey and forwarding it from render_key, so it reconciles like a built-in element:

rust
#[derive(PartialEq)]
struct Task {
    title: String,
    key: DiffKey,
}

impl Task {
    fn new(title: String) -> Self {
        Self {
            title,
            key: DiffKey::None,
        }
    }
}

impl KeyExt for Task {
    fn write_key(&mut self) -> &mut DiffKey {
        &mut self.key
    }
}

impl Component for Task {
    fn render(&self) -> impl IntoElement {
        label().text(self.title.clone())
    }

    fn render_key(&self) -> DiffKey {
        self.key.clone().or(self.default_key())
    }
}

fn app(ids: Vec<u64>) -> impl IntoElement {
    rect().children(
        ids.iter()
            .map(|id| Task::new(format!("Task {id}")).key(*id).into()),
    )
}

Internationalization (freya-i18n)

Enable with features = ["i18n"]. Uses Fluent (.ftl files) for translations.

1. Define .ftl files:

ftl
# en-US.ftl
hello_world = Hello, World!
hello = Hello, { $name }!

2. Initialize once in the root component:

rust
use freya::i18n::*;

let mut i18n = use_init_i18n(|| {
    I18nConfig::new(langid!("en-US"))
        .with_locale(Locale::new_static(langid!("en-US"), include_str!("../i18n/en-US.ftl")))
        .with_locale(Locale::new_static(langid!("es-ES"), include_str!("../i18n/es-ES.ftl")))
        .with_fallback(langid!("en-US"))
});

3. Translate in any descendant component:

rust
// t! panics if key missing, te! returns Result, tid! falls back to the key string
t!("hello_world")                  // "Hello, World!"
t!("hello", name: {"Alice"})       // "Hello, Alice!"
te!("hello_world")                 // Ok("Hello, World!")
tid!("missing-key")                // "message-id: missing-key should be translated"

4. Switch language at runtime:

rust
let mut i18n = I18n::get(); // retrieve from any descendant
i18n.set_language(langid!("es-ES"));

The instance lives in the root context shared by all windows, so calling use_init_i18n in each window reuses the same state.

Animations

Use use_animation for manual control and use_animation_transition to animate between two values reactively:

rust
// Manual: call .start() / .reverse() yourself
let mut anim = use_animation(|_| AnimColor::new((240, 240, 240), (200, 80, 80)).time(400));
rect().background(&*anim.read()).on_press(move |_| anim.start())

// Transition: re-runs automatically when the tracked value changes
let color = use_animation_transition(is_active, |from, to| AnimColor::new(from, to).time(300));
rect().background(&*color.read())

Animate colors (AnimColor), sizes, positions, and other numeric properties. Easing functions and sequencing are supported.

Routing

Enable with features = ["router"]. Define routes with #[derive(Routable)], render them with router::<Route>(), place the current page with outlet::<Route>(), and navigate with Link or RouterContext::get().replace(...):

rust
#[derive(Routable, Clone, PartialEq)]
enum Route {
    #[route("/")]
    Home,
    #[route("/settings")]
    Settings,
}

fn app() -> impl IntoElement {
    router::<Route>(|| RouterConfig::default())
}

Headless Testing

freya-testing lets you test components without a window. Use TestingRunner to mount a component, simulate interactions, and assert on state:

rust
use freya_testing::prelude::*;

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

runner.sync_and_update();
runner.click_cursor((15., 15.));
assert_eq!(*state.peek(), 1);

Call runner.render_to_file("out.png") to snapshot the current UI.

Icons

Enable with features = ["icons"]. Uses Lucide icons rendered as SVGs:

rust
use freya::icons;

SvgViewer::new(icons::lucide::antenna()).color((120, 50, 255)).expanded()

Rich Text Editing

Use use_editable to manage a text editor with cursor, selection, keyboard shortcuts, and virtualization. Wire it to a paragraph() element's event handlers and feed EditableEvents from mouse/keyboard events. See examples/ for full wiring.

Code Editor

Enable with features = ["code-editor"]. CodeEditorData holds a Rope-backed buffer with tree-sitter syntax highlighting. You bring your own tree-sitter grammar and highlights query via EditorLanguage, so any language can be supported (add the grammar crate, e.g. tree-sitter-rust, as a dependency). Pass None to disable highlighting. Then pass the data to the CodeEditor component:

rust
let editor = use_state(|| {
    let language = EditorLanguage::new(
        tree_sitter_rust::LANGUAGE,
        tree_sitter_rust::HIGHLIGHTS_QUERY,
    );
    let mut e = CodeEditorData::new(Rope::from_str(src), language);
    e.parse();
    e.measure(14., "Jetbrains Mono");
    e
});
CodeEditor::new(editor, focus.a11y_id())

Plotting

Enable with features = ["plot"]. Use the plot() element with a RenderCallback and draw into it using the Plotters API via PlotSkiaBackend:

rust
plot(RenderCallback::new(|ctx| {
    let backend = PlotSkiaBackend::new(ctx.canvas, ctx.font_collection, size).into_drawing_area();
    // ... Plotters drawing code
})).expanded()

Material Design

Enable with features = ["material-design"]. Adds style modifiers like .ripple() to built-in components:

rust
use freya::material_design::*;
Button::new().ripple().child("Click me")

HTML

Enable with features = ["html"]. Renders an HTML + CSS document with Blitz:

rust
use freya::html::*;
// The view is driven by a handle, which also exposes history navigation
// (back/forward) and the current URL. Sources can be remote or inline.
let mut handle = use_html(|| HtmlSource::url("https://example.com"));
HtmlView::new(handle).expanded();
handle.back();
handle.navigate("https://freyaui.dev");
use_html(|| HtmlSource::html("<h1>Hello</h1>"));

Terminal

Enable with features = ["terminal-pty"]. Spawns a PTY process and renders it as a terminal:

rust
use freya::terminal::*;
let mut cmd = CommandBuilder::new("bash");
cmd.env("TERM", "xterm-256color");
let handle = TerminalHandle::new(TerminalId::new(), PtyBackend::new(cmd), None).ok();
// Render with Terminal::new(handle) and forward keyboard events via handle.write_key()

Camera

Enable with features = ["camera"]. Streams frames from a webcam into reactive state:

rust
use freya::camera::*;
let camera = use_camera(CameraConfig::default);
CameraViewer::new(camera)

On macOS, call freya::camera::init() from main to request authorization before launching.

Borderless Windows

Enable with features = ["borderless"]. For windows using .with_decorations(false), register BorderlessPlugin (crate freya-borderless-plugin). Combine with TitlebarButton (titlebar feature) and rect().window_drag() for a custom titlebar.

rust
use freya::borderless::BorderlessPlugin;

launch(
    LaunchConfig::new()
        .with_plugin(BorderlessPlugin::new().with_corner_radius(12.))
        .with_window(
            WindowConfig::new(app)
                .with_decorations(false)
                .with_transparency(true)
                .with_background(Color::TRANSPARENT),
        ),
)

Developer Tools

Enable with features = ["devtools"]. Adds a real-time component tree inspector. Run the devtools app alongside your app to examine layout, props, and state.

Crate Features

Add to your Cargo.toml as needed:

toml
freya = { version = "...", features = ["router", "radio"] }
FeatureWhat it enables
routerPage routing (freya-router)
i18nInternationalization via Fluent (freya-i18n)
remote-assetLoad images/assets from remote URLs
radioGlobal state management (freya-radio)
queryAsync data fetching with caching (freya-query)
sdkGeneric utility APIs (freya-sdk)
plotChart/plotting via Plotters (freya-plotters-backend)
gifAnimated GIF support in GifViewer
calendarCalendar date-picker component
markdownMarkdown renderer component
iconsSVG icon library via Lucide (freya-icons)
material-designMaterial Design theme (freya-material-design)
htmlRender HTML + CSS via Blitz (freya-html)
terminalTerminal emulator (freya-terminal)
terminal-ptyBuilt-in PTY backend for the terminal, implies terminal
code-editorCode editing APIs (freya-code-editor)
cameraWebcam capture (freya-camera)
traySystem tray support
titlebarCustom window titlebar component
borderlessBorderlessPlugin helpers for undecorated windows (freya-borderless-plugin)
devtoolsDeveloper tools overlay
metricsMetrics overlay plugin (freya-metrics-plugin)
hotpathHot-path optimization
allAll of the above (except devtools/metrics/hotpath)

Further Reference

  • AGENTS.md (also symlinked as CLAUDE.md) in the repo root - authoritative dev workflow and Rust conventions for working on Freya itself.
  • crates/freya/src/_docs/ - in-source documentation for hooks, state management, components, routing, animations, and more.
  • examples/ - 150+ working examples covering every feature.

© marc2332, MIT. Rendered from Markdown: HTML in the file is shown as text, images as links, and headings moved down two levels. Raw file

Files

Just SKILL.md in plugins/freya/skills/freya of marc2332/freya.

Open the folder on GitHubat commit 4c52087

Compare with similar skills

Freya next to the 5 skills that share the most tags, products or categories with it. Stars are the repository's; “used in” counts other GitHub owners with a copy.

Freya compared with similar skills
SkillStarsUsed inTokensAuto-checkLicenceRepo updated
Freya this skillmarc2332/freya3.2k—~9.1kAutomated safety check: PassMIT
Frb Android Emulator Preparefzyzcjy/flutter_rust_bridge5.4k—~2.1kAutomated safety check: NotesMIT
Exeify Web2exe44886/Exeify178—~1.3kAutomated safety check: PassApache-2.0
Dpis Hyperos SmokeKwensiu/DPIS108—~715Automated safety check: PassGPL-3.0
Release Whiskerwhiskerrs/whisker140—~2kAutomated safety check: PassApache-2.0
Ddk New VersionYlarod/ddk177—~4.4kAutomated safety check: WarnNone

Similar skills

  • Frb Android Emulator Prepare

    fzyzcjy/flutter_rust_bridge

    A skill your agent uses when preparing, installing, diagnosing, or explaining the host Android Emulator environment for flutterrustbridge local runtime validation, including Android SDK command-line…

    5.4k GitHub stars~2.1k tokensUpdated yesterday
    MobileAuto-check: notes
  • Exeify Web2exe

    44886/Exeify

    把网页打包成 Windows exe 的技能。当用户想把「一个本地 HTML/CSS/JS 网页文件夹」或「一个在线网址」打包成可双击运行、免安装的独立 Windows .exe(用系统自带 WebView2 渲染,终端用户无需装任何东西)时使用;可设置窗口标题/尺寸/全屏、程序图标、启动页、源码保护等;还能同时输出安卓 APK(把同一个网页做成安卓 App,免 Android SDK)。仅…

    178 GitHub stars~1.3k tokensUpdated 9 days ago
    MobileAuto-check passed
  • Dpis Hyperos Smoke

    Kwensiu/DPIS

    Run automated DPIS HyperOS device smoke tests for package-specific dp/font emulation or replacement.

    108 GitHub stars~715 tokensUpdated 2 days ago
    MobileAuto-check passed
  • Release Whisker

    whiskerrs/whisker

    Prepare or publish a Whisker release across Rust crates, Android SDK, Gradle plugin, and SwiftPM; recover a partial release or diagnose a missing SDK pin.

    140 GitHub stars~2k tokensUpdated yesterday
    MobileAuto-check passed
  • Ddk New Version

    Ylarod/ddk

    Add or bump an Android GKI kernel version in the DDK repo (e.g.

    177 GitHub stars~4.4k tokensUpdated 1 mo ago
    MobileAuto-check: warnings
  • Frb Dev Env

    fzyzcjy/flutter_rust_bridge

    A skill your agent uses when the user wants Docker-based FRB development or Tart-based iOS Simulator validation.

    5.4k GitHub stars~5.5k tokensUpdated yesterday
    MobileAuto-check passed

Works with

Categories

Questions about Freya

What does Freya do?

Freya Rust GUI framework best practices, patterns, and conventions. Freya is an agent skill from marc2332/freya. Freya Rust GUI framework best practices, patterns, and conventions.

When should I use Freya?

Freya fits situations like: writing Freya components; working on a Freya project.

How do I install Freya in Claude Code?

Run `npx skills add marc2332/freya --skill freya -a claude-code`. Or copy the skill folder (plugins/freya/skills/freya in marc2332/freya) into .claude/skills/freya in your project. Claude Code loads it when a task matches its description.

How do I install Freya in Codex?

Run `npx skills add marc2332/freya --skill freya -a codex`. Or copy the skill folder (plugins/freya/skills/freya in marc2332/freya) into .agents/skills/freya in your project. Codex loads it when a task matches its description.

Can I use Freya in Cursor, Gemini CLI or GitHub Copilot?

Cursor, Gemini CLI, GitHub Copilot and OpenCode also load SKILL.md folders. With the skills CLI, run `npx skills add marc2332/freya --skill freya -a cursor` (or -a gemini-cli, github-copilot or opencode for the others). To copy it by hand, put the folder in .cursor/skills/freya, .gemini/skills/freya, .github/skills/freya and .opencode/skills/freya in your project.

What does Freya need to run?

Going by SKILL.md and its folder, Freya needs the command-line tools its instructions call (node).

Does Freya access the network?

SKILL.md names 2 domains. In commands or code: freyaui.dev; the agent is likely to contact it when it follows the instructions. As links in the text: projectfluent.org. This is read from the text; nothing was executed.

Is Freya safe to install?

Our automated static check of SKILL.md found no risky patterns, such as piping downloads into a shell, reading credential files or hidden Unicode. It is not a guarantee. Review the folder before installing.

What licence does Freya use?

Freya is published under the MIT licence (the repository's licence). It allows redistribution, so the full SKILL.md is shown on this page.

How many tokens does Freya use?

About 9.1k tokens (SKILL.md is roughly 36k characters). Agents keep only the skill's name and description in context until a task matches; then they load SKILL.md in full.

What are the alternatives to Freya?

Skills that share tags, products or a category with Freya: Frb Android Emulator Prepare (fzyzcjy/flutter_rust_bridge, 5.4k stars), Exeify Web2exe (44886/Exeify, 178 stars), Dpis Hyperos Smoke (Kwensiu/DPIS, 108 stars) and Release Whisker (whiskerrs/whisker, 140 stars). The comparison table on this page puts their stars, adoption, token cost, safety result and licence side by side.

Who maintains Freya?

marc2332 (a GitHub user) maintains it in marc2332/freya, which has 3,208 GitHub stars. The repository was last updated on October 7, 2026.

Source: marc2332/freya on GitHub. Facts on this page come from the repository at the commit we read; the author's words are quoted as theirs.