WebJs UI
An AI-first component library of 32 primitives, built on native HTML and styled with
Tailwind v4. The source is copied into your project, so you own every line and edit it
freely. Variant names, sizes, and the data-state conventions mirror shadcn,
so an agent trained on shadcn maps its knowledge across directly.
It is built for WebJs apps and styled with Tailwind v4. Tier-1 helpers are plain functions
returning class strings, and Tier-2 elements extend WebComponent from
@webjsdev/core, so the kit assumes the runtime a WebJs app already has.
Two tiers, one mental model
Tier 1 (23 components) is pure class-helper functions.
buttonClass(), cardClass(), and inputClass() return
Tailwind class strings that you apply to a real element, so a button is a real
<button> and a checkbox is a real <input>. Form
submission, autofill, browser validation, and screen readers all work natively, never
proxied. A few wrap a platform primitive instead of a plain element: accordion and
collapsible sit on <details>, progress on
<progress value max>, popover on the popover attribute.
import { cardClass, cardHeaderClass, cardTitleClass, cardContentClass } from '#components/ui/card.ts';
import { buttonClass } from '#components/ui/button.ts';
<div class=${cardClass()}>
<div class=${cardHeaderClass()}>
<h3 class=${cardTitleClass()}>Hello</h3>
</div>
<div class=${cardContentClass()}>
<button class=${buttonClass({ variant: 'default' })}>Click me</button>
</div>
</div>
Tier 2 (9 components) is a small set of stateful custom
elements, for the behaviour the platform still does not ship: dialogs, tabs, menus,
tooltips, hover cards, toggle groups, and toasts. Each one wraps the closest primitive it
can (<ui-dialog> drives a native <dialog>, tooltip
and hover-card use popover="manual") and adds only the open-state tracking,
focus trap, or queue on top.
<ui-dialog>
<ui-dialog-trigger>
<button class=${buttonClass({ variant: 'outline' })}>Edit profile</button>
</ui-dialog-trigger>
<ui-dialog-content>
<h2 data-slot="dialog-title" class=${dialogTitleClass()}>Edit profile</h2>
<form action="/profile" method="post">…</form>
</ui-dialog-content>
</ui-dialog>
Reach for Tier 1 by default. Reach for Tier 2 only when the browser does not ship the behaviour natively.
Install
In a WebJs app there is nothing to install. @webjsdev/ui is a hard dependency
of @webjsdev/cli, so a global WebJs install already carries it. A scaffolded
app does not pin the kit either: webjs ui add copies component source into
components/ui/, and those files import @webjsdev/core rather
than the kit.
webjs ui init webjs ui add button card dialog input label
init writes components.json, copies the cn() helper
into lib/utils.ts, and installs the theme tokens.
add resolves a component's transitive dependencies and copies the source into
components/ui/, which is yours to edit from that point on.
Commands
| Command | What it does |
|---|---|
init | Writes components.json, copies lib/utils.ts, installs the theme tokens. Exits non-zero if the tokens cannot be written, because an unstyled install with a clean exit code is worse than a failure. |
add <names...> | Resolves transitive dependencies, copies the source in, installs npm dependencies, and self-heals missing theme tokens. |
list [filter] | Lists everything in the registry. |
view <name> | Prints a component's helpers, its paste-ready example, and its full source. |
diff [name] | Compares your local copy against the live registry. |
info | Project diagnostics: the resolved config and registry URL. |
Resolution is local-first. init, add, list, and
view read the registry that ships inside the installed
@webjsdev/ui package, so an install is deterministic and works offline. Only
diff and an explicit custom --registry go to the network. A Tier-1
component's worked structural example is served on demand by
webjsui view <name> and by the read-only MCP ui tool, rather
than copied into your file.
Accessibility
Responsibility splits by tier, and it matters which half you own.
Tier-2 elements wire their own ARIA, so do not hand-add it: tabs
cross-links its triggers and panels and reports orientation, toggle-group runs a roving
tabindex with Arrow, Home, and End, dropdown-menu declares orientation and reflects
aria-disabled, dialog and alert-dialog name themselves from their title and
description on open, tooltip wires aria-describedby, hover-card exposes the
popup relationship, and sonner is a live region.
Tier-1 helpers return only classes, so the semantic element, the role, and
the ARIA are yours. Every Tier-1 component's JSDoc carries an
A11y (required for accessible output) block naming exactly what to supply: a
name on an icon-only button, a role on an alert, scope on table headers,
alt on an avatar image, a labelled <nav> with
aria-current="page" on pagination and breadcrumb.
Dependencies
None beyond Tailwind v4 and @webjsdev/core. No Radix, no clsx, no
tailwind-merge, no Floating UI, no Sonner. The cn() helper, the positioning,
the focus trap, and the toast queue are all hand-rolled, so the whole kit is auditable in
an afternoon and every line is yours to edit.
Migrating from shadcn
A Tier-2 element maps mechanically: <DialogContent> becomes
<ui-dialog-content>, and variant and size props keep their names. A
Tier-1 component has no wrapper at all, so <Button variant="outline">
becomes a real <button> carrying
buttonClass({ variant: 'outline' }). There is no Radix
asChild slot pattern to translate, because applying a class to the element you
already have is what asChild was working around. An
onValueChange prop becomes an
addEventListener('ui-value-change', fn).