Skip to content

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

CommandWhat it does
initWrites 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.
infoProject 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).