celestia-ui (rust crate)gpui 0.3.6Rust ≥ 1.97macOS · Linux · Windows

Desktop

Native Rust desktop components built directly on gpui (gpui-pre) — the desktop counterpart of @celestia-project/ui, drawing the same Celestia tokens as a compile-time theme. This surface shows the shape of the package; the reference docs carry the architecture, theming rules and the per-primitive component map.

~60gpui modules surfaced
40+primitive files
2 + customtheme families
2crates

Get started

What this package is

Two crates, one rule: colors come from roles, never from hex literals at a call site.

packages/ui wraps Base UI primitives with celestia tokens; celestia-ui wraps the gpui-component library the same way — one file per primitive, so most web files map 1:1 onto a desktop file (drawer.tsx → sheet.rs, toast.tsx → toast.rs, file-tree.tsx → tree.rs). It is not a port — the web components assume a DOM; these draw straight into a GPU window. What they share is the taxonomy and the design tokens, plus shadcn-shaped wrappers (Button and Badge carry real Variant props).

Get started

Run the gallery

The showcase binary renders one Card section per family through the same re-export layer an app would use — buttons and badges, inputs and selection, alerts and toasts, a live popover/dialog/sheet, an accordion, date/color/number pickers, both text editors (markdown toolbar + tree-sitter code mode), and a live theming section: radius presets, font faces, accent swaps and custom theme families. ⌘D / Ctrl+D toggles light/dark.

from the repo root
pnpm desktop
from packages/desktop/
cargo run -p celestia-desktop
PlatformToolchain
macOSNone beyond Rust ≥ 1.97 (edition 2024)
LinuxVulkan + Wayland/X11/fontconfig headers
WindowsMSVC
quality gates — inside packages/desktop/
cargo test --workspace                # unit + gpui TestAppContext tests (theme pins)
cargo fmt --all
cargo clippy --workspace --all-targets -- -D warnings

Components

One file per primitive

Every primitive gets its own file mirroring packages/ui's primitive/*.tsx — Celestia wrappers (button.rs, badge.rs, card.rs, the editors) plus per-primitive re-exports of gpui-component, each with the web → desktop mapping in its doc comment.

actions

button.rs (wrapper) · kbd.rs · link.rs · command.rs · menu.rs

web: button.tsx, kbd.tsx, link.tsx, command.tsx, dropdown/context/menubar

inputs

input.rs · textarea.rs · input_otp.rs · select.rs · combobox.rs · switch.rs · checkbox.rs · radio.rs · slider.rs · rating.rs · stepper.rs · label.rs · calendar.rs · date_picker.rs · color_picker.rs

web: input.tsx, textarea.tsx, input-otp.tsx, select.tsx, switch.tsx, checkbox.tsx, radio-group.tsx, slider.tsx, calendar.tsx, label.tsx

feedback

alert.rs · toast.rs · progress.rs · spinner.rs · skeleton.rs · shimmer.rs · tooltip.rs

web: alert.tsx, toast.tsx / sonner.tsx, progress.tsx, spinner.tsx, skeleton.tsx, tooltip.tsx

motion (zeron port)

motion.rs · loaders.rs · notice.rs · context_badge.rs

web: — desktop-only; ported from the reference/zeron GPUI app (see the usage guide)

overlays

popover.rs · dialog.rs · modal.rs · sheet.rs · hover_card.rs

web: popover.tsx, dialog.tsx, drawer.tsx / sheet.tsx, hover-card.tsx

data display

badge.rs (wrapper) · avatar.rs · breadcrumb.rs · table.rs · list.rs · tree.rs · pagination.rs · marker.rs · empty.rs

web: badge.tsx, avatar.tsx, breadcrumb.tsx, table.tsx, item.tsx, ai/file-tree.tsx, pagination.tsx, marker.tsx, empty.tsx

layout

separator.rs · tabs.rs · accordion.rs · collapsible.rs · carousel.rs · resizable.rs · scroll_area.rs · group_box.rs · sidebar.rs · dock.rs · title_bar.rs · status_bar.rs

web: separator.tsx, tabs.tsx, accordion.tsx / collapsible.tsx, carousel.tsx, resizable.tsx, scroll-area.tsx, sidebar.tsx — dock & window chrome are desktop-only

chat

bubble.rs · message.rs · attachment.rs

web: the ai/ family's bubble, chat-message, message-scroller, attachment

editors

text_editor.rs · code_editor.rs

web: text-editor.tsx / block-text-editor (markdown toolbar + tree-sitter code mode)

charts

charts.rs (chart · plot)

web: chart.tsx + the chart-*.tsx composites — series colors from palette(cx).chart(i)

card.rs · section_heading.rs

Celestia-only builds — components gpui-component lacks, written against the token layer. SectionHeading ports the agency idiom (eyebrow + title + description). The wrappers button.rs / badge.rs add shadcn-shaped Variant props on top of gpui primitives.

Guide

Using the components in a project

From an empty Rust binary to a themed window rendering Celestia components. Every snippet below compiles against the current crates — the library lives at packages/desktop/crates/ui, the package name is celestia-ui.

1Depend on the crate

celestia-ui depends on the framework and its two widget layers directly — gpui, gpui-base and gpui-component — with no facade crate in between. Every import names the layer it actually comes from.

toml — your Cargo.toml
# a crate inside packages/desktop — inherit the workspace entry
[dependencies]
celestia-ui = { workspace = true }

# an app outside the workspace — point at the library crate
[dependencies]
celestia-ui = { path = "../packages/desktop/crates/ui" }

2Initialize once, before any window

celestia_ui::init installs the gpui-component runtime and the Celestia light/dark palettes; Theme::change and system-appearance sync keep using them afterwards. The full window-opening bootstrap is in the last section of this page.

rust — main.rs
use gpui::App;

fn main() {
    gpui_platform::application().run(|cx: &mut App| {
        celestia_ui::init(cx); // runtime + Celestia light/dark themes
        // open windows — see "Bootstrap" below
    });
}

3Compose views from the components

Everything hangs off celestia_ui::components, with module paths mirroring the web files (components::badge ↔ badge.tsx). Stateless primitives are plain values; stateful ones are entities you create in the view constructor and render by reference.

rust — one view, wrapper variants, a stateful input
use celestia_ui::components::badge::{Badge, BadgeVariant};
use celestia_ui::components::button::{Button, ButtonVariant};
use celestia_ui::components::input::{Input, InputState};
use celestia_ui::components::Card;
use gpui::{Entity, Window};
use gpui_component::{h_flex, v_flex};
use gpui::prelude::*;

struct InviteView {
    email: Entity<InputState>,
}

impl InviteView {
    pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
        Self {
            // Stateful primitives are entities: create them in the
            // constructor, render them by reference below.
            email: cx.new(|cx| {
                InputState::new(window, cx).placeholder("[email protected]")
            }),
        }
    }
}

impl Render for InviteView {
    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
        v_flex().gap_3().child(
            Card::new()
                .title("Invite your team")
                .description("Teammates get a magic link by email.")
                .child(Input::new(&self.email).cleanable(true))
                .child(
                    h_flex()
                        .gap_2()
                        .items_center()
                        .child(Badge::new("Beta").variant(BadgeVariant::Brand))
                        .child(
                            Button::new("send")
                                .label("Send invite")
                                .variant(ButtonVariant::Primary)
                                .on_click(|_, _, _| {
                                    // handle it — event/window/cx come in here
                                }),
                        )
                        .child(
                            Button::new("later")
                                .label("Later")
                                .variant(ButtonVariant::Ghost),
                        ),
                ),
        )
    }
}

4Take colors from roles, never hex

cx.theme() resolves the semantic roles (background, muted, border, danger…) that flip with light/dark; palette(cx) carries the mode-independent product colors (brand, brand_deep, chart). The one rule of the package: no hex literals at a call site.

rust — theme roles + product palette
use celestia_ui::theme::ActiveTheme;
use gpui::{div, px};

let theme = cx.theme();
let row = div()
    .flex()
    .gap_2()
    .rounded(px(8.0))
    .bg(theme.muted)                       // semantic role — flips in dark mode
    .border_1()
    .border_color(theme.border)
    .text_color(theme.muted_foreground);

let wash = celestia_ui::palette(cx).brand().opacity(0.08); // product color, mode-independent

5Animate with the motion kit

The zeron-port family adds a motion catalog, cell loaders, the notice chip and context badges (desktop-only — no web counterpart). All loaders share one pulse clock: mount as many as you like, and a window with none scheduled draws nothing.

rust — motion.rs + the zeron-port primitives
use celestia_ui::components::context_badge::{BadgeDetail, MessageBadge, context_badge};
use celestia_ui::components::primitive::icon::PhosphorIcon;
use celestia_ui::components::loaders::{gradient_spinner, progress_ring, pulse_loader};
use celestia_ui::components::notice::{NoticeChipIcon, notice_chip};
use celestia_ui::motion;

// entrances — gpui Animations, snapped automatically under reduced motion
motion::fade_in("invite-enter", my_card)

// loaders — pass your view's id so the pulse clock can invalidate it
pulse_loader(8.0, cx.entity_id(), cx)
gradient_spinner(6.0, cx.entity_id(), cx)
progress_ring(65, 40.0, cx.theme().foreground)

// failure notice — tinted card, copy button, wrapping message
notice_chip(false, "Build failed", "cargo build exited with 101", NoticeChipIcon::Tile, cx)

// context pill — hovers into a card of location/tag/body rows
context_badge(
    "ctx-comments",
    &MessageBadge {
        icon: PhosphorIcon::FileCode,
        label: "2 comments".into(),
        details: vec![BadgeDetail {
            location: "src/main.rs:42".into(),
            tag: Some("R".into()),
            body: "early-return here".into(),
        }],
    },
    cx,
)

6Build your own primitive

Custom components are RenderOnce structs with #[derive(IntoElement)] — resolve theme colors inside render, not in the constructor, so light/dark switches apply on the next frame.

rust — a minimal custom primitive
use celestia_ui::theme::ActiveTheme;
use gpui::{App, IntoElement, RenderOnce, SharedString, Window, div, px};

#[derive(IntoElement)]
pub struct Pill {
    label: SharedString,
}

impl RenderOnce for Pill {
    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
        let theme = cx.theme();
        div()
            .px(px(6.0))
            .rounded(px(4.0))
            .bg(theme.muted)
            .border_1()
            .border_color(theme.border)
            .text_color(theme.muted_foreground)
            .child(self.label)
    }
}

Theming

The same tokens, compiled in

celestia_ui::init installs the light/dark palettes and replaces gpui-component's defaults — Theme::change and system-appearance sync keep using them. The swatches below are the live web tokens; the native gallery renders the same roles from the ported theme. The runtime layer over it is the next section.

primary
success
warning
info
danger
brand
brand-deep
chart-1
chart-5

Beyond the semantic roles, palette(cx) carries the mode-independent product colors gpui's Theme has no slot for: brand (#dc2626), brand_deep (#b51230), brand_foreground and the grayscale chart(0…4) ramp — mirroring the web where these live once on :root and never change in dark mode.

Theming

One config drives the theme at runtime

AppTheme is the desktop counterpart of the shadcn :root / .dark blocks in globals.css — semantic tokens, one radius, and one font, with overlay semantics over the compiled-in theme. Nothing per-component changes: every primitive already reads cx.theme(), so applying a config re-themes the whole window on the next frame.

rust — celestia_ui::theme::AppTheme, the runtime globals.css equivalent
use celestia_ui::theme::{hex, AppFont, AppTheme, Scheme};
use gpui::px;

let config = AppTheme {
    radius: px(10.),                    // one knob — radius_lg derives +4px
    font: AppFont {
        family: Some("Georgia".into()), // None = Geist (embedded)
        size: Some(px(18.)),            // None = 16px, also the rem size
        ..Default::default()
    },
    light: Scheme {
        primary: Some(hex(0x2563eb)),   // None = keep the theme.json value
        ..Default::default()
    },
    dark: Scheme {
        primary: Some(hex(0x6aa5ff)),
        ..Default::default()
    },
};

// Re-themes every window on the next frame — and survives later
// Theme::change / system-appearance switches.
config.apply(cx);

Overlay, not replacement

Every Scheme field is optional — None keeps the theme.json value, so AppTheme::default().apply(cx) is a re-assert of the compiled-in theme, not a change. Colors are hex(0xrrggbb) values, the config's unit, like a CSS variable value.

One token drives its whole fan-out

Setting primary re-colors the fills, their derived hover/active steps, ring, caret, links, the 22% selection wash and the primary-tinted surfaces — the same fan-out theme.json encodes by hand. The full shadcn set is covered per mode, plus a surface token for the chrome family.

One radius knob

radius maps to shadcn's --radius and lands on theme.radius everywhere; the large-element radius (dialogs, notifications) derives as radius + 4px — the shadcn --radius-xl step.

One font

AppFont sets the UI family and rem size (unset is the embedded Geist face at 16px) and the monospace family and size (unset is the platform mono face at 13px). apply writes both theme globals, so the window root and every code surface follow.

Light, dark and custom families

register adds a named light+dark pair built from tokens and activates it; select switches between families with the mode preserved; apply overlays whichever family is active and stores the tweak on it, so it survives switching away and back.

rust — light, dark and custom themes (theme/book.rs)
use celestia_ui::theme::{active, families, select, AppTheme, Scheme, hex};

// Register a named light+dark family from tokens — and activate it:
AppTheme {
    dark: Scheme {
        background: Some(hex(0x0b1120)),
        primary: Some(hex(0x38bdf8)),
        ..Default::default()
    },
    ..Default::default()
}
.register("Nocturne", cx);

select("Celestia", cx);  // switch back — light/dark mode is preserved
families(cx);            // ["Celestia", "Nocturne"]
active(cx);              // "Celestia"

The gpui-component theming surface is re-exported at celestia_ui::theme — Theme, ThemeColor and ActiveTheme — so apps reach the whole story from one import. Brand and the chart ramp stay mode-independent in palette(cx); the showcase’s Theming & Config section drives all of this live.

Apps

App bootstrap in three lines

Install the runtime + theme, open a window, wrap the root view in a gpui-component Root — then mount the overlay layers yourself, because Root::render does not draw them.

rust — the full reference bootstrap lives in crates/showcase/src/main.rs
gpui_platform::application().run(|cx: &mut App| {
    celestia_ui::init(cx);                    // runtime + Celestia light/dark themes
    cx.open_window(WindowOptions { ..TitleBar::window_options() }, |window, cx| {
        let view = cx.new(|cx| MyApp::new(window, cx));
        cx.new(|cx| Root::new(view, window, cx))
    })?;
    cx.activate(true);
});
rust — mount the overlay layers (WindowExt opens them: open_dialog / open_sheet / push_notification)
// inside MyApp::render — dialogs, sheets and toasts are invisible until
// the root view mounts their layers:
let dialog_layer = Root::render_dialog_layer(window, cx);
let sheet_layer = Root::render_sheet_layer(window, cx);
let notification_layer = Root::render_notification_layer(window, cx);
div().child(self.content(cx))
    .children(dialog_layer)
    .children(sheet_layer)
    .children(notification_layer)