Native desktop GUI framework for Bun + TypeScript/TSX. Retained UI, native rendering, no browser, DOM, or React runtime. Windows & Linux.
Rust
7
128 commits
updated Oct 1, 2026
Native desktop UI for Bun + TypeScript/TSX.
Tarve turns a TSX tree into a retained native interface. It does not use a browser, DOM, or React runtime. Layout is handled by Taffy, text shaping and editing by Parley, and rendering is selectable between GPU and low-memory CPU paths.
Current supported targets: Windows x64 and Linux x64
Runtime: Bun 1.4+
Package:
@tarve/core(CLI:tarve)License: Apache-2.0
@tarve/core JSX runtime.vello_cpu + softbuffer CPU renderer.VirtualList modes.bun --hot, and a native frame-time graph.For a published package:
bun add @tarve/core
bun add -d typescript @types/bun
Use this TypeScript configuration:
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"noEmit": true,
"jsx": "react-jsx",
"jsxImportSource": "@tarve/core",
"types": ["bun", "@tarve/core/assets"]
}
}
import { Button, Column, Text, Window, createApp } from "@tarve/core";
let count = 0;
function App() {
return (
<Window title="Counter" width={520} height={360}>
<Column flex={1} align="center" justify="center" gap={16}>
<Text size={42} weight={700}>{count}</Text>
<Button onClick={() => count++}>Increase</Button>
</Column>
</Window>
);
}
const app = createApp(App);
await app.ready;
await app.closed;
Run the source directly with Bun:
bun app.tsx
The package CLI compiles a Tarve application into a standalone executable and embeds the native runtime. Windows produces a .exe; Linux produces an ELF executable with no required extension. The target defaults to the current host. Published Tarve packages contain both Windows x64 and Linux x64 native runtimes, so --target can switch between those targets without rebuilding Tarve's Rust runtime.
bun run tarve build app.tsx --outfile dist/App.exe
bun run tarve build app.tsx --outfile dist/App
Cross-compile explicitly from either Windows x64 or Linux x64:
bun run tarve build app.tsx --target windows-x64 --outfile dist/App.exe
bun run tarve build app.tsx --target linux-x64 --outfile dist/App
The published package already contains both runtimes, so tarve build needs no Rust toolchain.
The build API is also exported:
import { build } from "@tarve/core/build";
await build({
entrypoint: "app.tsx",
target: "linux-x64",
outfile: "dist/App",
name: "My App"
});
createApp accepts renderer: "auto" | "gpu" | "cpu".
| Mode | Windows | Linux |
|---|---|---|
auto | Uses the native D3D11/DXGI GPU renderer by default. | Uses the Vello/WGPU GPU renderer by default. |
gpu | Uses D3D11/DXGI with Vello/WGPU fallback for initialization or recovery failures. If Vello cannot run on the available adapter, falls back to CPU. | Uses Vello/WGPU with the platform graphics backend selected by WGPU. If the adapter lacks Vello's required shader features, falls back to CPU. |
cpu | Uses vello_cpu + softbuffer. | Uses vello_cpu + softbuffer. |
const app = createApp(App, {
renderer: "cpu"
});
An explicit renderer in createApp takes precedence over TARVE_RENDERER. WGPU_BACKEND can select a WGPU backend for development and diagnostics. On Windows, setting it also selects the Vello/WGPU path instead of the normal D3D11 renderer. An explicit WGPU_BACKEND keeps GPU failures strict instead of silently falling back to CPU, which makes backend-specific diagnostics reliable.
Tarve exports native primitives and higher-level controls from the main @tarve/core entry point.
| Area | Components |
|---|---|
| Window and layout | Window, TitleBar, View, Row, Column, Scroll, ScrollArea, Portal, Resizable, AspectRatio, Direction |
| Text and media | Text, Typography, Image, Svg, Icon |
| Inputs | Input, TextArea, Checkbox, Switch, RadioGroup, Select, NativeSelect, Slider, InputOTP |
| Buttons and feedback | Button, ButtonGroup, Toggle, ToggleGroup, Badge, Progress, Spinner, Skeleton, Alert |
| Overlays | Modal/Dialog, AlertDialog, Popover, Tooltip, DropdownMenu, ContextMenu, Sheet, Drawer, HoverCard, CommandPalette |
| Navigation | Tabs, Accordion, Breadcrumb, Pagination, NavigationMenu, Menubar, Sidebar, Collapsible |
| Data | List, VirtualList, Table, DataTable, DataGrid, TreeView |
| Rich content | Markdown, Code, Diff |
| App/chat UI | Card, Field, Item, Empty, Questionnaire, Attachment, Message, Bubble, MessageScroller |
The public API also includes lower-level composition primitives such as Pressable, declarative SVG elements, reusable Style helpers, component adapters, and desktop APIs exposed through AppHandle.
Tarve uses semantic theme tokens. lightTheme is the default; darkTheme can be selected per window and themes can be changed at runtime without recreating the native window.
import { Button, Window, darkTheme, theme } from "@tarve/core";
function App() {
return (
<Window theme={darkTheme}>
<Button
style={{
background: theme.colors.primary,
hover: { background: theme.colors.primaryHover },
focus: { outlineWidth: 2 },
disabled: { foreground: theme.colors.mutedForeground }
}}
>
Continue
</Button>
</Window>
);
}
Every colour property (background, foreground, borderColor, outlineColor, rich-content and diff colours, state styles and the window background) accepts hex (#rgb, #rgba, #rrggbb, #rrggbbaa), rgb()/rgba(), hsl()/hsla(), common names such as transparent or white, and theme tokens. Unsupported colour strings throw when the view is compiled instead of rendering black.
transform translates and scales a node and its subtree about the box centre: { x, y, scale, scaleX, scaleY } or CSS syntax such as "translateY(-2px) scale(1.02)". It is paint-only (layout does not move) but hit testing and hover follow the transformed box, and it transitions like other paint properties. Rotation is not supported yet.
Borders accept borderStyle with the same values as outlineStyle (dashed, dotted, double, groove, ridge, inset, outset, none); non-solid styles apply when all four border widths are equal. Text, button labels, Input and TextArea accept textShadow: { x, y, blur, color }, an offset copy of the glyphs (blur is the CSS blur radius; 0 is a solid copy, larger values blur it identically on every renderer); it also works inside state styles such as hover.
boxShadow takes { x, y, blur, spread, color, inset } or a list of up to 8 (the first paints on top). blur is the CSS blur radius; spread grows or shrinks the shadow and its corner radius; inset paints inside the padding box. Outer shadows are never drawn under their own box, so translucent backgrounds stay clean. Shadows do not affect layout, work in state styles such as hover, and render the same on D3D11, Vello GPU and the CPU renderer.
background also takes a gradient, as a CSS string or an object: "linear-gradient(135deg, #2563eb, #9333ea 80%)", "linear-gradient(to top right, red, blue)", "radial-gradient(circle at 25% 75%, #fff, #000)", "repeating-linear-gradient(45deg, #000 0 6px, #fff 6px 12px)", "conic-gradient(from 90deg at 30% 50%, red, blue 25%, red)", or { type: "linear", angle: 135, stops: ["#2563eb", { color: "#9333ea", offset: 0.8 }] }. Angles accept deg, rad, turn and grad. Stop positions take % or px (object offsets: a number is a 0..1 fraction, strings take px/%); two positions make a hard stop, omitted ones are spread and positions never go back, as in CSS. Radial gradients take circle/ellipse, a size keyword (closest-side, closest-corner, farthest-side, farthest-corner, the default) or explicit radii (circle 40px, ellipse 50% 20px; object size: 40 or ["50%", "20px"]), and an at centre in keywords, percentages or px, including edge offsets such as at right 10px bottom 20%. Conic gradients take from <angle> (0 points up, clockwise) and the same at positions; their stops are angles (deg, turn, rad, grad) or % of a turn (object { type: "conic", from: 90, at: { x: 0.3, y: 0.5 }, stops }, with number offsets as fractions of a turn). repeating-linear-gradient, repeating-radial-gradient and repeating-conic-gradient tile the stop pattern; patterns finer than a pixel paint their average colour. Stop colours accept the same colour syntax and theme tokens. With transition, gradients with the same kind, stop count, units, size and repeat mode animate stop by stop; anything else, including colour ↔ gradient, switches instantly, as in CSS. borderColor and foreground take the same gradients: a gradient border fills the ring between the border and padding boxes, and dashed, dotted and double borders paint their strokes with the gradient (CSS border-image would ignore border-style and paint a solid ring; Tarve keeps the style; inset/outset/groove/ridge shade the first stop), and gradient text on Text fills the glyphs with the gradient laid over the text box, like CSS background-clip: text (it is rasterized once and cached, so it renders identically everywhere; Button labels and icons use the first stop). Other length units (em, rem, vw, vh, …) and calc() are not supported yet; they are planned for the whole style system rather than for gradients alone.
hover styles apply to any node under the pointer and to its ancestors, as CSS :hover does; the innermost interactive node still receives events. With transition, opacity, radius, background, foreground, borderColor, boxShadow and textShadow animate natively both on JS updates and on hover/active/focus/disabled changes, starting from the value on screen:
<Column style={{
background: "#ffffff",
boxShadow: { y: 2, blur: 6, color: "#0f172a22" },
hover: { background: "#eff6ff", boxShadow: { y: 14, blur: 28, color: "#2563eb44" } },
transition: { all: { duration: 220, easing: "easeOut" } },
}} />
Both shadows also accept CSS syntax: boxShadow: "inset 0 1px 2px rgba(0,0,0,.2), 0 8px 24px -4px #0003" and textShadow: "1px 2px #0006" (or "none"). Lengths are px or unitless 0; colours may be hex, rgb()/rgba(), hsl()/hsla(), a few names or theme tokens. Invalid strings throw when the view is compiled. Shadow lists of different lengths interpolate against transparent layers. motionFrom and AnimatePresence enter/exit accept numeric properties only.
Create derived themes with createTheme or Theme.create. Theme tokens cover surfaces, foregrounds, borders, focus outlines, selection, caret, scrollbars, modal overlays, rich-content colors, and control states.
Tarve transitions retained native values without a Bun timer or per-frame TSX render. Bun sends the new target once; Rust owns interpolation, layout/paint invalidation, frame scheduling, retargeting, and completion.
The first motion surface supports numeric width, height, top, right, bottom, left, opacity, and radius. Transitions can use linear, ease, easeIn, easeOut, or easeInOut, with optional duration and delay in milliseconds.
<View
id="details-panel"
motionFrom={{ opacity: 0, width: 240 }}
onTransitionEnd={({ property }) => {
console.log(`${property} finished`);
}}
style={{
width: expanded ? 420 : 280,
opacity: expanded ? 1 : 0.72,
radius: 16,
transition: {
width: { duration: 220, easing: "easeOut" },
opacity: { duration: 160, easing: "linear" },
radius: { duration: 220, easing: "easeOut" },
},
}}
/>
Changing a target while it is already moving retargets from the current interpolated value, so the node does not jump back to its previous declarative target. motionFrom is mount-only: it provides the initial numeric value when a native node is first created.
Use AnimatePresence when a node must stay mounted long enough to finish an exit transition. Keep the presence boundary rendered and toggle present; removing the boundary itself cannot retain its child for exit.
<AnimatePresence
id="details-presence"
present={detailsOpen}
enter={{ opacity: 0 }}
exit={{ opacity: 0 }}
transition={{ opacity: { duration: 180, easing: "easeOut" } }}
>
<Card id="details-card" style={{ opacity: 1 }}>
<Text>Project details</Text>
</Card>
</AnimatePresence>
Base numeric style targets participate in native motion. Interactive hover, focus, focusVisible, active, and disabled overrides are still applied immediately rather than creating state-transition tracks.
Markdown, Code, and Diff are native leaf nodes, so large documents do not expand into thousands of TSX children.
<Column
highlight={{ query: search, activeIndex: currentMatch }}
onHighlight={({ matchCount }) => {
totalMatches = matchCount;
}}
>
<Markdown source={document} />
<Code
code={snippet}
language="tsx"
showLineNumbers
/>
<Diff
source={patch}
wordDiff
maxLines={expanded ? undefined : 80}
onShowMore={() => {
expanded = true;
}}
/>
</Column>
Markdown supports GFM structure including tables, task lists, links, quotes, lists, inline code, and fenced code. Embedded HTML is displayed as literal text.
Code supports syntax highlighting through Syntect, optional language/path detection, selectable text, line numbers, and horizontal scrolling.
Diff accepts either a unified/Git patch in source or an oldText/newText pair. It supports word-level changes, per-file sections, collapsible paths, line limits, line-click events, and selection/copy without diff chrome.
createTextSearchController and findRanges provide search/navigation helpers for Text, Markdown, Code, and Diff.
Image accepts local paths, data URLs, encoded PNG/JPEG/WebP/SVG bytes, and raw RGBA8 pixels. Raw RGBA is sent directly to the native image cache, so live pixel updates do not require a PNG encode/decode round trip.
<Image
src={{
rgba: previewPixels,
width: 640,
height: 360,
cacheKey: "live-preview",
}}
width={640}
height={360}
fit="contain"
/>
HTTP(S) loading is explicit and asynchronous. loadImageSource supports AbortSignal, payload limits, and a bounded in-process LRU cache:
const controller = new AbortController();
const avatar = await loadImageSource("https://example.com/avatar.webp", {
signal: controller.signal,
});
render(() => <Image src={avatar} width={96} height={96} fit="cover" />);
The native decoded-image cache is bounded and renderer-side image caches retain only images used by the current scene/frame.
List keeps all items mounted and is appropriate for normal collections.
VirtualList has three modes:
itemHeight for the smallest and simplest runtime path.estimatedItemHeight, a stable id, and keyForItem. Tarve measures rows natively and preserves a keyed scroll anchor as row heights change or items are prepended/reordered.itemCount and windowStart when the application owns a larger logical data set and passes only a mounted window.Fixed-height example:
<VirtualList
items={rows}
itemHeight={36}
height={360}
offset={offset}
onScroll={(nextOffset) => {
offset = nextOffset;
}}
renderItem={(row) => <Text>{row.label}</Text>}
/>
Variable-height example:
<VirtualList
id="messages"
items={messages}
estimatedItemHeight={52}
height={420}
offset={offset}
keyForItem={(message) => message.id}
alignment="bottom"
followTail
onScroll={(nextOffset) => {
offset = nextOffset;
}}
renderItem={(message) => (
<Text>{message.body}</Text>
)}
/>
Variable lists retain measured heights by key and can keep a focused editor row alive while normal windowing moves it outside the visible range.
Native text search and copy operate on the mounted logical window. A retained editor row that is parked only to preserve focus is excluded from search, copy, accessibility, and tab order. For an externally windowed data set, search the full logical data set in the application/provider, call scrollToItem for the chosen result, and apply the native highlight after that row mounts.
Input and TextArea use native text editing over Parley, including caret placement, selection, clipboard operations, grapheme-aware deletion, IME composition, wrapping, and scrolling. Password input remains masked in rendering and accessibility output.
Editors keep a native per-field undo/redo history (Ctrl+Z, Ctrl+Y / Ctrl+Shift+Z) that coalesces continuous typing or deletion into word-sized steps and resets when a controlled value changes externally. onSubmit(value) fires on Enter for Input; TextArea submits on Ctrl/Cmd+Enter, or on Enter with submitOnEnter (Shift+Enter then inserts a newline). The caret blinks after activity and settles solid after 10 s idle, so a focused editor schedules no idle frames. When the clipboard holds no text, Ctrl+V delivers files or a bitmap to the focused element's onPaste as { kind: "files", files } or { kind: "image", width, height, rgba }.
On Windows, Tarve projects the native tree through AccessKit/UI Automation with roles, names, values, states, actions, focus, text ranges, selection, scroll ranges, live regions, and field relationships. On Linux, the same AccessKit tree is exposed over AT-SPI (D-Bus) for screen readers such as Orca; it activates only when an assistive technology connects.
createApp returns an AppHandle with lifecycle and desktop integration methods.
const app = createApp(App);
const unregisterSave = app.registerHotkey("Ctrl+S", () => {
saveProject();
});
const file = await app.openFileDialog({
title: "Open project",
filters: [{ name: "JSON", extensions: ["json"] }]
});
const files = await app.openFilesDialog();
const folder = await app.openFolderDialog();
const target = await app.saveFileDialog({
fileName: "report.json"
});
unregisterSave();
Other AppHandle APIs include update, close, focus, scrollToItem, event listeners, debug inspection, and deterministic screen capture.
Window.onCloseRequest can cancel a user-initiated close with event.preventDefault(). Window.position accepts centered/edge/corner presets or explicit logical desktop coordinates.
TestRenderer exposes the supported automation surface over the same native renderer used by applications. It provides ID/text/role locators, pointer and keyboard actions, waitFor, waitForIdle, capture, and an optional hidden-window mode for CI runs that still require a real renderer surface.
const test = await createTestRenderer(App, {
renderer: "cpu",
headless: true,
});
await test.getByRole("button", { name: "Save" }).click();
await test.getById("project-name").fill("Tarve demo");
await test.waitFor((snapshot) =>
snapshot.nodes.some((node) => node.text === "Saved") || false
);
// After an interaction or app update starts a transition:
await test.advanceMotion(50);
const midpoint = await test.inspect();
await test.capture("dist/saved.png");
test.close();
advanceMotion(milliseconds) first flushes any queued declarative update, then advances the native motion clock without sleeping. Once used, that app instance stays on deterministic motion time, which makes intermediate geometry and captures reproducible. waitForIdle() also waits for activeMotions === 0.
Use launchTestProcess to isolate CPU/GPU test runs in child processes. readPngRgba, comparePngCaptures, and assertPngMatches are reusable pixel-regression helpers; Tarve's own visual smoke tests use the same public PNG decoder.
Render TitleBar inside Window to opt into Tarve-managed window chrome:
<Window title="My app" width={1000} height={700}>
<TitleBar title="My app" />
<View flex={1}>
{/* application */}
</View>
</Window>
Tarve keeps native dragging, resize hit testing, and minimize/maximize/close behavior on supported platforms. Windows additionally applies Windows 11 corner and maximized/fullscreen border handling.
Tarve includes native Icon and SVG primitives. The optional @tarve/react-icons package adapts static SVG icon components from libraries such as Lucide, Phosphor, Heroicons, and Tabler without adding React as a core Tarve dependency.
import {
lucideReactAdapter,
phosphorReactAdapter,
reactSvgAdapter
} from "@tarve/react-icons";
const app = createApp(App, {
componentAdapters: [
reactSvgAdapter,
lucideReactAdapter,
phosphorReactAdapter
]
});
createApp accepts a structured application-level error handler:
const app = createApp(App, {
onError(event) {
console.error(
event.source,
event.event,
event.targetId,
event.error
);
}
});
Recoverable callback, listener, hotkey, bridge, request, dialog, and update failures are reported through onError. A failed native update keeps the last confirmed tree.
Run an app in development mode with hot reload:
$env:TARVE_DEV = "1"; bun --hot app.tsx
TARVE_DEV=1 bun --hot app.tsx
With dev: true (or TARVE_DEV=1):
bun --hot re-evaluates the entry, render() remounts the new view into the already open window instead of opening a new one. app.remount(view) does the same explicitly and resets component state.ready.Independently of dev mode, the native frame-time overlay draws the CPU cost of the last 120 presented frames against a 16.7 ms budget line:
const app = createApp(App, { frameOverlay: true }); // or TARVE_FRAME_OVERLAY=1
app.setFrameOverlay(false);
Neither tool schedules frames on its own, so an idle window still presents zero frames. The graph refreshes on the next repaint.
The examples/ directory in the repository is an independent Bun consumer project using only public Tarve APIs. From a clone:
bun run setup:examples
cd examples
bun run check
bun run basic
bun run counter
bun run components
bun run forms
bun run intrinsics
bun run large-list
bun run rich-content
bun run diff
bun run motion
bun run studio
bun run performance
From the repository root, bun run dev --entry examples/<name>.tsx runs an example with hot reload and the development tools enabled.
setup:examples builds host-local package staging itself; it does not require the universal publishable tarball.
All example UI copy and example documentation is written in English.
Linux file dialogs use the XDG desktop portal through rfd. Opening external links uses xdg-open when available and falls back to gio open.
On WSLg, Tarve prefers winit's X11 backend when DISPLAY is available; native Linux keeps winit's normal Wayland/X11 auto-selection. This avoids WSLg-specific Wayland Broken pipe event-loop failures without changing backend selection on ordinary Linux desktops.
Building Tarve from source, the test and smoke-test gates, and the release process are documented in CONTRIBUTING.md. Project notes: production readiness, performance measurements, release process.
Tarve is pre-1.0 and currently targets Windows x64 and Linux x64. The current application bootstrap model uses one native app/window lifetime per process; multi-window support is not yet part of the public runtime model.
Tarve is licensed under the Apache License 2.0.
Rust
55.3%
TypeScript
37.9%
JavaScript
5.5%
PowerShell
1.1%
Native desktop GUI framework for Bun + TypeScript/TSX. Retained UI, native rendering, no browser, DOM, or React runtime. Windows & Linux.
Rust
7
128 commits
updated Oct 1, 2026
Native desktop UI for Bun + TypeScript/TSX.
Tarve turns a TSX tree into a retained native interface. It does not use a browser, DOM, or React runtime. Layout is handled by Taffy, text shaping and editing by Parley, and rendering is selectable between GPU and low-memory CPU paths.
Current supported targets: Windows x64 and Linux x64
Runtime: Bun 1.4+
Package:
@tarve/core(CLI:tarve)License: Apache-2.0
@tarve/core JSX runtime.vello_cpu + softbuffer CPU renderer.VirtualList modes.bun --hot, and a native frame-time graph.For a published package:
bun add @tarve/core
bun add -d typescript @types/bun
Use this TypeScript configuration:
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"noEmit": true,
"jsx": "react-jsx",
"jsxImportSource": "@tarve/core",
"types": ["bun", "@tarve/core/assets"]
}
}
import { Button, Column, Text, Window, createApp } from "@tarve/core";
let count = 0;
function App() {
return (
<Window title="Counter" width={520} height={360}>
<Column flex={1} align="center" justify="center" gap={16}>
<Text size={42} weight={700}>{count}</Text>
<Button onClick={() => count++}>Increase</Button>
</Column>
</Window>
);
}
const app = createApp(App);
await app.ready;
await app.closed;
Run the source directly with Bun:
bun app.tsx
The package CLI compiles a Tarve application into a standalone executable and embeds the native runtime. Windows produces a .exe; Linux produces an ELF executable with no required extension. The target defaults to the current host. Published Tarve packages contain both Windows x64 and Linux x64 native runtimes, so --target can switch between those targets without rebuilding Tarve's Rust runtime.
bun run tarve build app.tsx --outfile dist/App.exe
bun run tarve build app.tsx --outfile dist/App
Cross-compile explicitly from either Windows x64 or Linux x64:
bun run tarve build app.tsx --target windows-x64 --outfile dist/App.exe
bun run tarve build app.tsx --target linux-x64 --outfile dist/App
The published package already contains both runtimes, so tarve build needs no Rust toolchain.
The build API is also exported:
import { build } from "@tarve/core/build";
await build({
entrypoint: "app.tsx",
target: "linux-x64",
outfile: "dist/App",
name: "My App"
});
createApp accepts renderer: "auto" | "gpu" | "cpu".
| Mode | Windows | Linux |
|---|---|---|
auto | Uses the native D3D11/DXGI GPU renderer by default. | Uses the Vello/WGPU GPU renderer by default. |
gpu | Uses D3D11/DXGI with Vello/WGPU fallback for initialization or recovery failures. If Vello cannot run on the available adapter, falls back to CPU. | Uses Vello/WGPU with the platform graphics backend selected by WGPU. If the adapter lacks Vello's required shader features, falls back to CPU. |
cpu | Uses vello_cpu + softbuffer. | Uses vello_cpu + softbuffer. |
const app = createApp(App, {
renderer: "cpu"
});
An explicit renderer in createApp takes precedence over TARVE_RENDERER. WGPU_BACKEND can select a WGPU backend for development and diagnostics. On Windows, setting it also selects the Vello/WGPU path instead of the normal D3D11 renderer. An explicit WGPU_BACKEND keeps GPU failures strict instead of silently falling back to CPU, which makes backend-specific diagnostics reliable.
Tarve exports native primitives and higher-level controls from the main @tarve/core entry point.
| Area | Components |
|---|---|
| Window and layout | Window, TitleBar, View, Row, Column, Scroll, ScrollArea, Portal, Resizable, AspectRatio, Direction |
| Text and media | Text, Typography, Image, Svg, Icon |
| Inputs | Input, TextArea, Checkbox, Switch, RadioGroup, Select, NativeSelect, Slider, InputOTP |
| Buttons and feedback | Button, ButtonGroup, Toggle, ToggleGroup, Badge, Progress, Spinner, Skeleton, Alert |
| Overlays | Modal/Dialog, AlertDialog, Popover, Tooltip, DropdownMenu, ContextMenu, Sheet, Drawer, HoverCard, CommandPalette |
| Navigation | Tabs, Accordion, Breadcrumb, Pagination, NavigationMenu, Menubar, Sidebar, Collapsible |
| Data | List, VirtualList, Table, DataTable, DataGrid, TreeView |
| Rich content | Markdown, Code, Diff |
| App/chat UI | Card, Field, Item, Empty, Questionnaire, Attachment, Message, Bubble, MessageScroller |
The public API also includes lower-level composition primitives such as Pressable, declarative SVG elements, reusable Style helpers, component adapters, and desktop APIs exposed through AppHandle.
Tarve uses semantic theme tokens. lightTheme is the default; darkTheme can be selected per window and themes can be changed at runtime without recreating the native window.
import { Button, Window, darkTheme, theme } from "@tarve/core";
function App() {
return (
<Window theme={darkTheme}>
<Button
style={{
background: theme.colors.primary,
hover: { background: theme.colors.primaryHover },
focus: { outlineWidth: 2 },
disabled: { foreground: theme.colors.mutedForeground }
}}
>
Continue
</Button>
</Window>
);
}
Every colour property (background, foreground, borderColor, outlineColor, rich-content and diff colours, state styles and the window background) accepts hex (#rgb, #rgba, #rrggbb, #rrggbbaa), rgb()/rgba(), hsl()/hsla(), common names such as transparent or white, and theme tokens. Unsupported colour strings throw when the view is compiled instead of rendering black.
transform translates and scales a node and its subtree about the box centre: { x, y, scale, scaleX, scaleY } or CSS syntax such as "translateY(-2px) scale(1.02)". It is paint-only (layout does not move) but hit testing and hover follow the transformed box, and it transitions like other paint properties. Rotation is not supported yet.
Borders accept borderStyle with the same values as outlineStyle (dashed, dotted, double, groove, ridge, inset, outset, none); non-solid styles apply when all four border widths are equal. Text, button labels, Input and TextArea accept textShadow: { x, y, blur, color }, an offset copy of the glyphs (blur is the CSS blur radius; 0 is a solid copy, larger values blur it identically on every renderer); it also works inside state styles such as hover.
boxShadow takes { x, y, blur, spread, color, inset } or a list of up to 8 (the first paints on top). blur is the CSS blur radius; spread grows or shrinks the shadow and its corner radius; inset paints inside the padding box. Outer shadows are never drawn under their own box, so translucent backgrounds stay clean. Shadows do not affect layout, work in state styles such as hover, and render the same on D3D11, Vello GPU and the CPU renderer.
background also takes a gradient, as a CSS string or an object: "linear-gradient(135deg, #2563eb, #9333ea 80%)", "linear-gradient(to top right, red, blue)", "radial-gradient(circle at 25% 75%, #fff, #000)", "repeating-linear-gradient(45deg, #000 0 6px, #fff 6px 12px)", "conic-gradient(from 90deg at 30% 50%, red, blue 25%, red)", or { type: "linear", angle: 135, stops: ["#2563eb", { color: "#9333ea", offset: 0.8 }] }. Angles accept deg, rad, turn and grad. Stop positions take % or px (object offsets: a number is a 0..1 fraction, strings take px/%); two positions make a hard stop, omitted ones are spread and positions never go back, as in CSS. Radial gradients take circle/ellipse, a size keyword (closest-side, closest-corner, farthest-side, farthest-corner, the default) or explicit radii (circle 40px, ellipse 50% 20px; object size: 40 or ["50%", "20px"]), and an at centre in keywords, percentages or px, including edge offsets such as at right 10px bottom 20%. Conic gradients take from <angle> (0 points up, clockwise) and the same at positions; their stops are angles (deg, turn, rad, grad) or % of a turn (object { type: "conic", from: 90, at: { x: 0.3, y: 0.5 }, stops }, with number offsets as fractions of a turn). repeating-linear-gradient, repeating-radial-gradient and repeating-conic-gradient tile the stop pattern; patterns finer than a pixel paint their average colour. Stop colours accept the same colour syntax and theme tokens. With transition, gradients with the same kind, stop count, units, size and repeat mode animate stop by stop; anything else, including colour ↔ gradient, switches instantly, as in CSS. borderColor and foreground take the same gradients: a gradient border fills the ring between the border and padding boxes, and dashed, dotted and double borders paint their strokes with the gradient (CSS border-image would ignore border-style and paint a solid ring; Tarve keeps the style; inset/outset/groove/ridge shade the first stop), and gradient text on Text fills the glyphs with the gradient laid over the text box, like CSS background-clip: text (it is rasterized once and cached, so it renders identically everywhere; Button labels and icons use the first stop). Other length units (em, rem, vw, vh, …) and calc() are not supported yet; they are planned for the whole style system rather than for gradients alone.
hover styles apply to any node under the pointer and to its ancestors, as CSS :hover does; the innermost interactive node still receives events. With transition, opacity, radius, background, foreground, borderColor, boxShadow and textShadow animate natively both on JS updates and on hover/active/focus/disabled changes, starting from the value on screen:
<Column style={{
background: "#ffffff",
boxShadow: { y: 2, blur: 6, color: "#0f172a22" },
hover: { background: "#eff6ff", boxShadow: { y: 14, blur: 28, color: "#2563eb44" } },
transition: { all: { duration: 220, easing: "easeOut" } },
}} />
Both shadows also accept CSS syntax: boxShadow: "inset 0 1px 2px rgba(0,0,0,.2), 0 8px 24px -4px #0003" and textShadow: "1px 2px #0006" (or "none"). Lengths are px or unitless 0; colours may be hex, rgb()/rgba(), hsl()/hsla(), a few names or theme tokens. Invalid strings throw when the view is compiled. Shadow lists of different lengths interpolate against transparent layers. motionFrom and AnimatePresence enter/exit accept numeric properties only.
Create derived themes with createTheme or Theme.create. Theme tokens cover surfaces, foregrounds, borders, focus outlines, selection, caret, scrollbars, modal overlays, rich-content colors, and control states.
Tarve transitions retained native values without a Bun timer or per-frame TSX render. Bun sends the new target once; Rust owns interpolation, layout/paint invalidation, frame scheduling, retargeting, and completion.
The first motion surface supports numeric width, height, top, right, bottom, left, opacity, and radius. Transitions can use linear, ease, easeIn, easeOut, or easeInOut, with optional duration and delay in milliseconds.
<View
id="details-panel"
motionFrom={{ opacity: 0, width: 240 }}
onTransitionEnd={({ property }) => {
console.log(`${property} finished`);
}}
style={{
width: expanded ? 420 : 280,
opacity: expanded ? 1 : 0.72,
radius: 16,
transition: {
width: { duration: 220, easing: "easeOut" },
opacity: { duration: 160, easing: "linear" },
radius: { duration: 220, easing: "easeOut" },
},
}}
/>
Changing a target while it is already moving retargets from the current interpolated value, so the node does not jump back to its previous declarative target. motionFrom is mount-only: it provides the initial numeric value when a native node is first created.
Use AnimatePresence when a node must stay mounted long enough to finish an exit transition. Keep the presence boundary rendered and toggle present; removing the boundary itself cannot retain its child for exit.
<AnimatePresence
id="details-presence"
present={detailsOpen}
enter={{ opacity: 0 }}
exit={{ opacity: 0 }}
transition={{ opacity: { duration: 180, easing: "easeOut" } }}
>
<Card id="details-card" style={{ opacity: 1 }}>
<Text>Project details</Text>
</Card>
</AnimatePresence>
Base numeric style targets participate in native motion. Interactive hover, focus, focusVisible, active, and disabled overrides are still applied immediately rather than creating state-transition tracks.
Markdown, Code, and Diff are native leaf nodes, so large documents do not expand into thousands of TSX children.
<Column
highlight={{ query: search, activeIndex: currentMatch }}
onHighlight={({ matchCount }) => {
totalMatches = matchCount;
}}
>
<Markdown source={document} />
<Code
code={snippet}
language="tsx"
showLineNumbers
/>
<Diff
source={patch}
wordDiff
maxLines={expanded ? undefined : 80}
onShowMore={() => {
expanded = true;
}}
/>
</Column>
Markdown supports GFM structure including tables, task lists, links, quotes, lists, inline code, and fenced code. Embedded HTML is displayed as literal text.
Code supports syntax highlighting through Syntect, optional language/path detection, selectable text, line numbers, and horizontal scrolling.
Diff accepts either a unified/Git patch in source or an oldText/newText pair. It supports word-level changes, per-file sections, collapsible paths, line limits, line-click events, and selection/copy without diff chrome.
createTextSearchController and findRanges provide search/navigation helpers for Text, Markdown, Code, and Diff.
Image accepts local paths, data URLs, encoded PNG/JPEG/WebP/SVG bytes, and raw RGBA8 pixels. Raw RGBA is sent directly to the native image cache, so live pixel updates do not require a PNG encode/decode round trip.
<Image
src={{
rgba: previewPixels,
width: 640,
height: 360,
cacheKey: "live-preview",
}}
width={640}
height={360}
fit="contain"
/>
HTTP(S) loading is explicit and asynchronous. loadImageSource supports AbortSignal, payload limits, and a bounded in-process LRU cache:
const controller = new AbortController();
const avatar = await loadImageSource("https://example.com/avatar.webp", {
signal: controller.signal,
});
render(() => <Image src={avatar} width={96} height={96} fit="cover" />);
The native decoded-image cache is bounded and renderer-side image caches retain only images used by the current scene/frame.
List keeps all items mounted and is appropriate for normal collections.
VirtualList has three modes:
itemHeight for the smallest and simplest runtime path.estimatedItemHeight, a stable id, and keyForItem. Tarve measures rows natively and preserves a keyed scroll anchor as row heights change or items are prepended/reordered.itemCount and windowStart when the application owns a larger logical data set and passes only a mounted window.Fixed-height example:
<VirtualList
items={rows}
itemHeight={36}
height={360}
offset={offset}
onScroll={(nextOffset) => {
offset = nextOffset;
}}
renderItem={(row) => <Text>{row.label}</Text>}
/>
Variable-height example:
<VirtualList
id="messages"
items={messages}
estimatedItemHeight={52}
height={420}
offset={offset}
keyForItem={(message) => message.id}
alignment="bottom"
followTail
onScroll={(nextOffset) => {
offset = nextOffset;
}}
renderItem={(message) => (
<Text>{message.body}</Text>
)}
/>
Variable lists retain measured heights by key and can keep a focused editor row alive while normal windowing moves it outside the visible range.
Native text search and copy operate on the mounted logical window. A retained editor row that is parked only to preserve focus is excluded from search, copy, accessibility, and tab order. For an externally windowed data set, search the full logical data set in the application/provider, call scrollToItem for the chosen result, and apply the native highlight after that row mounts.
Input and TextArea use native text editing over Parley, including caret placement, selection, clipboard operations, grapheme-aware deletion, IME composition, wrapping, and scrolling. Password input remains masked in rendering and accessibility output.
Editors keep a native per-field undo/redo history (Ctrl+Z, Ctrl+Y / Ctrl+Shift+Z) that coalesces continuous typing or deletion into word-sized steps and resets when a controlled value changes externally. onSubmit(value) fires on Enter for Input; TextArea submits on Ctrl/Cmd+Enter, or on Enter with submitOnEnter (Shift+Enter then inserts a newline). The caret blinks after activity and settles solid after 10 s idle, so a focused editor schedules no idle frames. When the clipboard holds no text, Ctrl+V delivers files or a bitmap to the focused element's onPaste as { kind: "files", files } or { kind: "image", width, height, rgba }.
On Windows, Tarve projects the native tree through AccessKit/UI Automation with roles, names, values, states, actions, focus, text ranges, selection, scroll ranges, live regions, and field relationships. On Linux, the same AccessKit tree is exposed over AT-SPI (D-Bus) for screen readers such as Orca; it activates only when an assistive technology connects.
createApp returns an AppHandle with lifecycle and desktop integration methods.
const app = createApp(App);
const unregisterSave = app.registerHotkey("Ctrl+S", () => {
saveProject();
});
const file = await app.openFileDialog({
title: "Open project",
filters: [{ name: "JSON", extensions: ["json"] }]
});
const files = await app.openFilesDialog();
const folder = await app.openFolderDialog();
const target = await app.saveFileDialog({
fileName: "report.json"
});
unregisterSave();
Other AppHandle APIs include update, close, focus, scrollToItem, event listeners, debug inspection, and deterministic screen capture.
Window.onCloseRequest can cancel a user-initiated close with event.preventDefault(). Window.position accepts centered/edge/corner presets or explicit logical desktop coordinates.
TestRenderer exposes the supported automation surface over the same native renderer used by applications. It provides ID/text/role locators, pointer and keyboard actions, waitFor, waitForIdle, capture, and an optional hidden-window mode for CI runs that still require a real renderer surface.
const test = await createTestRenderer(App, {
renderer: "cpu",
headless: true,
});
await test.getByRole("button", { name: "Save" }).click();
await test.getById("project-name").fill("Tarve demo");
await test.waitFor((snapshot) =>
snapshot.nodes.some((node) => node.text === "Saved") || false
);
// After an interaction or app update starts a transition:
await test.advanceMotion(50);
const midpoint = await test.inspect();
await test.capture("dist/saved.png");
test.close();
advanceMotion(milliseconds) first flushes any queued declarative update, then advances the native motion clock without sleeping. Once used, that app instance stays on deterministic motion time, which makes intermediate geometry and captures reproducible. waitForIdle() also waits for activeMotions === 0.
Use launchTestProcess to isolate CPU/GPU test runs in child processes. readPngRgba, comparePngCaptures, and assertPngMatches are reusable pixel-regression helpers; Tarve's own visual smoke tests use the same public PNG decoder.
Render TitleBar inside Window to opt into Tarve-managed window chrome:
<Window title="My app" width={1000} height={700}>
<TitleBar title="My app" />
<View flex={1}>
{/* application */}
</View>
</Window>
Tarve keeps native dragging, resize hit testing, and minimize/maximize/close behavior on supported platforms. Windows additionally applies Windows 11 corner and maximized/fullscreen border handling.
Tarve includes native Icon and SVG primitives. The optional @tarve/react-icons package adapts static SVG icon components from libraries such as Lucide, Phosphor, Heroicons, and Tabler without adding React as a core Tarve dependency.
import {
lucideReactAdapter,
phosphorReactAdapter,
reactSvgAdapter
} from "@tarve/react-icons";
const app = createApp(App, {
componentAdapters: [
reactSvgAdapter,
lucideReactAdapter,
phosphorReactAdapter
]
});
createApp accepts a structured application-level error handler:
const app = createApp(App, {
onError(event) {
console.error(
event.source,
event.event,
event.targetId,
event.error
);
}
});
Recoverable callback, listener, hotkey, bridge, request, dialog, and update failures are reported through onError. A failed native update keeps the last confirmed tree.
Run an app in development mode with hot reload:
$env:TARVE_DEV = "1"; bun --hot app.tsx
TARVE_DEV=1 bun --hot app.tsx
With dev: true (or TARVE_DEV=1):
bun --hot re-evaluates the entry, render() remounts the new view into the already open window instead of opening a new one. app.remount(view) does the same explicitly and resets component state.ready.Independently of dev mode, the native frame-time overlay draws the CPU cost of the last 120 presented frames against a 16.7 ms budget line:
const app = createApp(App, { frameOverlay: true }); // or TARVE_FRAME_OVERLAY=1
app.setFrameOverlay(false);
Neither tool schedules frames on its own, so an idle window still presents zero frames. The graph refreshes on the next repaint.
The examples/ directory in the repository is an independent Bun consumer project using only public Tarve APIs. From a clone:
bun run setup:examples
cd examples
bun run check
bun run basic
bun run counter
bun run components
bun run forms
bun run intrinsics
bun run large-list
bun run rich-content
bun run diff
bun run motion
bun run studio
bun run performance
From the repository root, bun run dev --entry examples/<name>.tsx runs an example with hot reload and the development tools enabled.
setup:examples builds host-local package staging itself; it does not require the universal publishable tarball.
All example UI copy and example documentation is written in English.
Linux file dialogs use the XDG desktop portal through rfd. Opening external links uses xdg-open when available and falls back to gio open.
On WSLg, Tarve prefers winit's X11 backend when DISPLAY is available; native Linux keeps winit's normal Wayland/X11 auto-selection. This avoids WSLg-specific Wayland Broken pipe event-loop failures without changing backend selection on ordinary Linux desktops.
Building Tarve from source, the test and smoke-test gates, and the release process are documented in CONTRIBUTING.md. Project notes: production readiness, performance measurements, release process.
Tarve is pre-1.0 and currently targets Windows x64 and Linux x64. The current application bootstrap model uses one native app/window lifetime per process; multi-window support is not yet part of the public runtime model.
Tarve is licensed under the Apache License 2.0.
Rust
55.3%
TypeScript
37.9%
JavaScript
5.5%
PowerShell
1.1%