Component Libraries
A component library is a cargo crate of .wisp components. An app adds it to [dependencies] and uses its components as <ui:Button>, with no import line. It is Wisp's svelte-package, with Cargo as the registry.
Make One
wisp new ui-kit --lib
That writes a crate. Its prefix is its name with _ for - (32 characters at most). The examples below shorten it to ui:
# Cargo.toml
[package]
name = "ui-kit"
version = "0.1.0"
edition = "2024"
links = "ui-kit" # lets Cargo hand the components to apps
[package.metadata.wisp]
prefix = "ui_kit" # <ui_kit:Button>; shorten it, say to "ui"
[dependencies]
wisp = { version = "0.1", package = "wisp-web-rt" }
[build-dependencies]
wisp-build = { version = "0.1", package = "wisp-web-build" }
// build.rs
fn main() {
wisp_build::package();
}
Components go in src/components, as in an app. src/lib.rs holds the Rust they use. A component's markup and its props' defaults see the crate's pub items (Tone, shout below), not the app's.
<!-- src/components/Button.wisp -->
{@props label: &str, tone: Tone = Tone::Primary}
<button class="btn ${tone.class()}">${label}</button>
<style>
.btn { border-radius: 6px; }
.primary { background: #7c3aed; color: #fff; }
</style>
<!-- src/components/Card.wisp -->
{@props title: &str}
<section class="card">
<h2>${shout(title)}</h2>
{@render children()}
<Button label="More" tone=${Tone::Quiet} />
</section>
Inside the library its own components go by their bare names (<Button>). A bare name always means the library's own component. An app's Button cannot replace it. A library uses no other components.
wisp check in the library checks it as the build will. It checks its manifest (prefix, links, the build script), then every component (props, markup, scoped CSS, browser code). wisp package [args] checks it and runs cargo package (the args go to it). cargo package builds the crate alone as crates.io would. cargo publish sends it.
Use One
# the app's Cargo.toml
[dependencies]
ui-kit = { path = "../ui-kit" } # or a version, or git
<ui:Card title="Hello">
<p>From the app.</p>
<ui:Button label="Save" />
</ui:Card>
<ui:Counter start=${3} client:visible />
<Button /> <!-- the app's own, a different component -->
In Rust, ui::Button::html("Save", ui_kit::Tone::Primary) draws one to a string, as Card::html(..) does for an app's component. The module ui also has the library's own items (ui::shout(..)), so a prefix that is the crate's name hides none of it. A prefix is a lowercase word of 32 characters at most, not wisp, a Rust keyword, or std, core, alloc, proc_macro or test.
- Props: the build checks them like an app's component's. A missing or unknown prop is an error in your file.
- CSS: each component's
<style>has a scope of its own (.w-…by its file)./_app/app.cssserves it with the app's. - Browser code: a component with a
<script>,{:…}oron:clickgets its own module. Only the pages that draw it load it.client:visibleand the otherclient:props make it an island, as for an app's component. - Editors:
wisp lsphovers<ui:Button>with its props and crate, completes it, and go to definition opens its file in the library. tree-sitter (Neovim, Helix, Zed, Emacs) highlights it as a component. wisp mcplists the library's components with the app's.- What the app sees: only the libraries it depends on itself. Wisp ignores a library's own dependencies. A library uses no other library's components.
- One version: two versions of one library in the app's dependency graph fail Cargo's own
linkscheck (one package perlinksvalue), before Wisp runs. - Names never collide: the app's
<Button>and<ui:Button>are two components. Two libraries with the same prefix are an error that says what to do. Rename one in the app (or as a section,[package.metadata.wisp.prefixes]),
[package.metadata.wisp]
prefixes = { "acme-ui" = "acme" } # <acme:Button>
How It Works
When the crate has links, Cargo passes a build script's cargo::metadata= lines to the build scripts of the crates that depend on it, as DEP_<LINKS>_* variables. wisp_build::package checks the library and emits its components folder, prefix and crate name. The app's wisp_build::run reads them, so a path, git or registry dependency works alike. The build finds a renamed dependency by its new name (kit = { package = "ui-kit" }, in the app's [dependencies] or inherited with kit = { workspace = true } from [workspace.dependencies]). That and prefixes are all the build reads of the app's Cargo.toml.
The build copies each library's src/components to .wisp/libs/<prefix>/, with an index, .wisp/libs/index. Git ignores it. The build writes it only when it changed. A library dropped from Cargo.toml goes. wisp dev, wisp check and the editor read those, so they need no Cargo run of their own. The next build makes a deleted .wisp/libs again. Until the app's first build finds a library, its tags are an error that says to build once. The build compiles each component into the app like its own (one straight-line render function, no boxing). Its module imports the library's crate (use ::ui_kit::*) in place of the app's modules. A library costs nothing on the request path.
With wisp dev running, an edit in a path-dependency library reaches the app at its next cargo build (restart wisp dev). The app's own files reload as always.
Plugins and Layers
A component library ships components only. To ship pages too, use a plugin crate (use = ["kit"]) or a layer (extends = ["../base"]). A layer shares its src/lib too. A layer component that imports $lib/motion.js works in the app. The app's own file of the same name wins. See Config. Both lists take "double" or 'single' quoted strings. A malformed list is a build error, not an empty one.
Is This Page Useful?