Native web components. Zero runtime. No framework required.
BareDOM is a library of 54 UI components built entirely on web standards — Custom Elements v1, Shadow DOM, and ES modules. There is no framework runtime, no virtual DOM, and no JavaScript framework peer dependency. Every component is a native HTML element that you register once and use anywhere.
The core rendering model is deliberately simple:
DOM = f(attributes, properties)
Components are stateless. Every visual state is derived directly from attributes and properties at render time. There are no hidden reactive systems, no internal signals, no component lifecycles to manage. Set an attribute, the DOM updates. Remove it, the DOM updates back.
BareDOM is authored in ClojureScript and compiled to optimised, minified ES modules using Google Closure's advanced compilation pass.
BareDOM has been created using Claude Code. The CLAUDE.md file is added to the repository for your convenience.
I started working on BareDOM after going though all the motions that Clojure developers often go through when deciding to build a UI. I worked with Reagent and Re-frame and in general the experience was pretty good. At some point I was building larger UI's with reusable components that were pretty basic (e.g. inputs, buttons etc). For complex UI's the bundle size became larger and larger.
I wondered if there would be a better way to do this, and I ended up reading about web components. It seemed like a good idea to try that. For me it as a research exercise, trying to understand how it all works. I tried to build a few, and had to learn how to do that in Clojurescript. Building a larger set of web components is quite a bit of work.
When Claude Code appeared, I was thinking about a project to try it out with, and web components seemed like a good fit. I picked up the project again and started to build Clojurescript based web components assisted by Claude. A good experiment to work with AI tooling and build something I find interesting myself.
The project is still in an alpha state. The components and demo's work, but there are bound to be some things not working properly yet. Feel free to give it a spin.
Works in any stack. Because components are native HTML elements, they work wherever HTML works — vanilla JavaScript, React, Vue, Svelte, Angular, server-rendered HTML, or a static page. No adapter layer, no wrapper library.
No framework lock-in. Your components are not tied to the framework you are building with today. Migrate your app, keep your components.
Tree-shakeable by design. Each component is a separate ES module. Import only what you use; bundle tools eliminate the rest automatically.
Full theming with CSS custom properties. Every visual detail — colours, spacing, radius, shadows, typography — is exposed as a --x-<component>-<property> CSS custom property. Override at any scope: globally, per-page, per-instance.
Light and dark mode included. All components adapt automatically to prefers-color-scheme. No JavaScript required, no class toggling.
Accessibility built in. ARIA roles, live regions, keyboard navigation, focus management, and prefers-reduced-motion support are part of the component, not an afterthought. You do not need to layer accessibility on top.
Open Shadow DOM. Shadow roots are mode: "open" — inspectable in DevTools, styleable via ::part(), and testable with standard DOM APIs.
BareDOM can be consumed in three ways. Pick the one that matches your stack.
This is the primary distribution for ClojureScript projects. Add the dependency to your deps.edn:
{:deps {com.github.avanelsas/baredom {:mvn/version "0.1.10-alpha"}}}
Or, if you use Leiningen, add to :dependencies in project.clj:
[com.github.avanelsas/baredom "0.1.10-alpha"]
Components live under the app.exports namespace. Require only what you use — unused namespaces are eliminated by the Closure compiler:
(ns my-app.core
(:require [app.exports.x-button :as x-button]
[app.exports.x-alert :as x-alert]
[app.exports.x-toaster :as x-toaster]
[app.exports.x-toast :as x-toast]))
(defn- register-components! []
(x-button/register!)
(x-alert/register!)
(x-toaster/register!)
(x-toast/register!))
Call register-components! once in your init! entry point. Registration is idempotent — calling register! on an already-registered element is a no-op.
If your project uses npm, install the package:
npm install @vanelsas/baredom
Or add it manually to package.json:
{
"dependencies": {
"@vanelsas/baredom": "^0.1.0-alpha"
}
}
Each component is a separate ES module — import only what you need:
import { init as initButton } from "@vanelsas/baredom/x-button";
import { init as initAlert } from "@vanelsas/baredom/x-alert";
initButton();
initAlert();
shadow-cljs users using npm: no extra configuration needed. shadow-cljs resolves npm packages from
node_modulesautomatically. You can use["@vanelsas/baredom/x-button" :as x-button]in:requireand call(.init x-button).
BareDOM components are standard ES modules. Load them directly in any HTML page using a CDN — no npm, no bundler, no framework required.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>My App</title>
</head>
<body>
<x-button variant="primary">Hello</x-button>
<x-alert type="success" text="It works!"></x-alert>
<script type="module">
import { init as initButton } from "https://esm.sh/@vanelsas/baredom/x-button";
import { init as initAlert } from "https://esm.sh/@vanelsas/baredom/x-alert";
initButton();
initAlert();
</script>
</body>
</html>
<script type="module"> is required because BareDOM components are ES modules. Each init() call registers the custom element with the browser. Only the components you import are loaded — unused components cost nothing.
Every attribute can be set directly in markup:
<x-button variant="secondary" size="sm" disabled>Cancel</x-button>
<x-checkbox checked></x-checkbox>
<x-alert type="warning" text="Check your input." dismissible></x-alert>
<x-progress value="65" max="100"></x-progress>
Boolean attributes follow the HTML convention: presence means true, absence means false.
<x-button id="btn" variant="primary">Click me</x-button>
<x-tabs id="tabs" value="home">
<x-tab value="home">Home</x-tab>
<x-tab value="settings">Settings</x-tab>
</x-tabs>
<script type="module">
import { init as initButton } from "https://esm.sh/@vanelsas/baredom/x-button";
import { init as initTabs } from "https://esm.sh/@vanelsas/baredom/x-tabs";
import { init as initTab } from "https://esm.sh/@vanelsas/baredom/x-tab";
initButton(); initTabs(); initTab();
document.getElementById("btn").addEventListener("click", () => {
console.log("button clicked");
});
document.getElementById("tabs").addEventListener("value-change", e => {
console.log("active tab:", e.detail.value);
});
</script>
Custom events carry a detail payload — check the individual component docs for the event name and detail shape.
<style>
:root {
--x-button-radius: 4px;
--x-button-bg-primary: #0a5c99;
}
</style>
CSS custom properties cascade normally into the open Shadow DOM. No JavaScript required for theming.
The examples below use the Clojars distribution. If you installed via npm, replace the app.exports.* requires with ["@vanelsas/baredom/x-button" :as x-button] etc., and call (.init x-button) instead of (x-button/register!).
Require each component namespace you need and call register! on it once, before any rendering. Only the components you require are included in your build.
(ns my-app.core
(:require
[app.exports.x-button :as x-button]
[app.exports.x-alert :as x-alert]
[app.exports.x-toaster :as x-toaster]
[app.exports.x-toast :as x-toast]))
(defn- register-components! []
(x-button/register!)
(x-alert/register!)
(x-toaster/register!)
(x-toast/register!))
Call register-components! once in your init! entry point. Registration is idempotent — calling register! on an already-registered element is a no-op.
BareDOM components are plain DOM elements. You need no framework to use them — only a small helper that turns ClojureScript data structures into DOM nodes. Copy the following into your project as renderer.cljs:
(ns my-app.renderer
(:require [clojure.string :as str]))
;;; ── Prop helpers ──────────────────────────────────────────────────────────
(defn- on-key? [k]
(str/starts-with? (name k) "on-"))
(defn- event-name [k]
;; :on-click → "click" :on-value-change → "value-change"
(subs (name k) 3))
(defn- set-prop! [el k v]
(let [attr (name k)]
(cond
(on-key? k) (.addEventListener el (event-name k) v)
(nil? v) (.removeAttribute el attr)
(true? v) (.setAttribute el attr "")
(false? v) (.removeAttribute el attr)
:else (.setAttribute el attr (str v)))))
;;; ── DOM creation ──────────────────────────────────────────────────────────
(declare create-nodes)
(defn- create-element [[tag & args]]
(let [has-props? (and (seq args) (map? (first args)))
props (when has-props? (first args))
children (if has-props? (rest args) args)
el (.createElement js/document (name tag))]
(doseq [[k v] props]
(set-prop! el k v))
(doseq [node (mapcat create-nodes children)]
(.appendChild el node))
el))
(defn create-nodes [x]
(cond
(nil? x) []
(false? x) []
(string? x) [(.createTextNode js/document x)]
(number? x) [(.createTextNode js/document (str x))]
(vector? x) [(create-element x)]
(seq? x) (mapcat create-nodes x)
:else []))
;;; ── Mount ─────────────────────────────────────────────────────────────────
(defn render! [container view-fn]
(set! (.-innerHTML container) "")
(doseq [node (create-nodes (view-fn))]
(.appendChild container node)))
(defn mount! [container view-fn state-atom]
(render! container view-fn)
(add-watch state-atom ::render
(fn [_ _ _ _]
(render! container view-fn))))
What it does:
set-prop! — routes :on-* keys to addEventListener; boolean true sets the attribute to ""; false / nil removes it; everything else calls setAttributecreate-nodes / create-element — recursively turns hiccup vectors into DOM nodesrender! — clears a container element and mounts the result of calling a view functionmount! — same as render!, and also add-watches a state atom so the view re-renders on every state changeViews are plain ClojureScript functions that return nested vectors. The first element of each vector is a keyword matching the element tag name. An optional map of props follows, then children.
;; String and number attributes
[:x-button {:variant "primary"} "Save changes"]
[:x-button {:variant "secondary" :size "sm"} "Cancel"]
;; Boolean attributes — true sets the attribute, false/nil removes it
[:x-button {:variant "danger" :disabled true} "Delete"]
[:x-button {:variant "primary" :loading true} "Saving…"]
[:x-checkbox {:checked true}]
[:x-checkbox {:indeterminate true}]
;; Nesting
[:x-grid {:columns "2" :gap "md"}
[:x-card "First card"]
[:x-card "Second card"]]
[:x-grid {:columns "4" :gap "md"}
[:x-stat {:label "Revenue" :value "$48,295" :trend "up" :variant "positive"}]
[:x-stat {:label "Users" :value "12,483" :trend "up"}]
[:x-stat {:label "Orders" :value "1,429" :trend "neutral"}]
[:x-stat {:label "Churn" :value "2.4%" :trend "down" :variant "danger"}]]
;; Slots — use the :slot attribute to target named slots
[:x-navbar {:label "My App"}
[:span {:slot "brand" :style "font-weight:700"} "My App"]
[:div {:slot "actions"}
[:x-button {:variant "ghost" :size "sm"} "Sign out"]]]
Event listeners are declared inline using :on-<event-name> keys. The key is stripped of on- and the remainder becomes the event name passed to addEventListener. Custom component events follow the same pattern — use the full event name after on-.
(defonce app-state (atom {:active-tab "overview"
:sidebar-collapsed false}))
;; Standard DOM event
[:x-button
{:variant "ghost"
:on-click (fn [_] (swap! app-state update :sidebar-collapsed not))}
"Toggle sidebar"]
;; Custom component event — :on-value-change listens for "value-change"
[:x-tabs
{:value (:active-tab @app-state)
:on-value-change (fn [e]
(swap! app-state assoc
:active-tab (.. e -detail -value)))}
[:x-tab {:value "overview"} "Overview"]
[:x-tab {:value "components"} "Components"]
[:x-tab {:value "settings"} "Settings"]]
;; Custom event with detail payload
[:x-alert
{:type "success" :text "Changes saved." :dismissible true
:on-x-alert-dismiss (fn [e]
(js/console.log "dismissed by:" (.. e -detail -reason)))}]
;; Sidebar with open/collapse state
[:x-sidebar
{:open (:sidebar-open @app-state)
:collapsed (:sidebar-collapsed @app-state)
:placement "left"
:on-toggle (fn [e]
(swap! app-state assoc :sidebar-open (.. e -detail -open)))}
;; ... nav items ...
]
Wire everything together in your init!:
(defn view []
[:x-container {:size "xl" :padding "lg"}
;; ... your UI built from component vectors ...
])
(defn init! []
(register-components!)
(renderer/mount! (.getElementById js/document "app") view app-state))
mount! calls view immediately and re-calls it on every swap! or reset! to app-state. The entire view is re-created from scratch on each render — no diffing, no virtual DOM, just plain DOM construction driven by the current value of the atom.
Override CSS custom properties at any scope:
/* Global overrides */
:root {
--x-button-radius: 4px;
--x-alert-radius: 8px;
}
/* Per-instance override */
#sidebar-save-btn {
--x-button-bg-primary: #0a5c99;
}
| Tag | Description |
|---|---|
<x-button> | Action control. Variants: primary, secondary, tertiary, ghost, danger. Sizes: sm, md, lg. States: disabled, loading, pressed. Icon slots. |
<x-checkbox> | Boolean input. Reflects checked and indeterminate states to attributes. |
<x-copy> | Copy-to-clipboard utility button with success feedback. |
<x-currency-field> | Formatted currency input with locale-aware masking. |
<x-date-picker> | Calendar-based date selection with keyboard navigation. |
<x-fieldset> | Groups related form controls with a styled legend. |
<x-file-download> | Download trigger that initiates a file transfer. |
<x-form> | Form wrapper with coordinated validation state. |
<x-form-field> | Label + input wrapper with error and hint text slots. |
<x-radio> | Single-choice input within a radio group. |
<x-search-field> | Search input with integrated clear button and search icon. |
<x-select> | Dropdown select control with custom styling. |
<x-slider> | Range slider with step, min/max, and value display. |
<x-stepper> | Multi-step form progress indicator with navigation. |
<x-switch> | Toggle switch for boolean settings. |
<x-text-area> | Multi-line text input with auto-resize option. |
| Tag | Description |
|---|---|
<x-alert> | Semantic alert banner. Types: info, success, warning, error. Auto-dismiss with timeout-ms. Fires x-alert-dismiss. |
<x-badge> | Small inline label for counts, states, and categories. |
<x-chip> | Compact tag component, optionally removable. |
<x-notification-center> | Notification hub for aggregating and managing in-app notifications. |
<x-progress> | Linear progress bar with determinate and indeterminate modes. |
<x-progress-circle> | Circular progress indicator for compact spaces. |
<x-skeleton> | Animated loading placeholder that mirrors content shape. |
<x-spinner> | Inline loading spinner with size and colour variants. |
<x-toast> | Single transient notification with enter/exit animations and auto-dismiss. |
<x-toaster> | Toast manager. Positions a queue of <x-toast> elements, enforces max-toasts, and fires x-toaster-dismiss. |
| Tag | Description |
|---|---|
<x-breadcrumbs> | Hierarchical path trail with separator customisation. |
<x-menu> | Vertical menu container coordinating <x-menu-item> children. |
<x-menu-item> | Individual menu entry with icon, label, description, and keyboard support. |
<x-navbar> | Top navigation bar with responsive slot layout. |
<x-pagination> | Page navigation controls with first/previous/next/last and page-size selection. |
<x-sidebar> | Collapsible side navigation panel with collapse/expand animation. |
<x-tab> | Individual tab within an <x-tabs> container. |
<x-tabs> | Tab container that coordinates <x-tab> children, manages active state, and fires change events. |
| Tag | Description |
|---|---|
<x-card> | Surface container. Variants: elevated, outlined, filled, ghost. Interactive mode available. |
<x-collapse> | Expandable/collapsible section with animated height transition. |
<x-container> | Responsive max-width container with configurable padding. |
<x-divider> | Horizontal or vertical visual separator. |
<x-grid> | CSS Grid layout component with responsive column configuration. |
<x-spacer> | Flexible spacing element for flexbox and grid layouts. |
| Tag | Description |
|---|---|
<x-avatar> | User photo or initials display. Shape, size, and status dot variants. |
<x-avatar-group> | Overlapping avatar stack for representing multiple users. |
<x-chart> | Data visualisation component for common chart types. |
<x-stat> | KPI / metric card with value, label, trend, and icon slots. |
<x-table> | Data grid using CSS subgrid. Supports sorting, single/multi-select, striping, and accessible captions. |
<x-table-cell> | Table cell for header and data modes, with sort indicator and alignment control. |
<x-table-row> | Table row with interactive selection and x-table-row-select event. |
<x-timeline> | Vertical timeline container that coordinates <x-timeline-item> children. |
<x-timeline-item> | Individual timeline event with time, icon, heading, and body slots. |
| Tag | Description |
|---|---|
<x-cancel-dialogue> | Confirmation modal for destructive cancel actions. |
<x-command-palette> | Keyboard-accessible global search and command interface. |
<x-context-menu> | Right-click / long-press contextual action menu. |
<x-drawer> | Off-canvas sliding panel, configurable from any edge. |
<x-dropdown> | Positioned dropdown container for menus and selection. |
<x-modal> | Centred dialog with backdrop, focus trap, and Escape to close. |
<x-popover> | Anchored popover for tooltips, help text, and contextual UI. |
Stateless. No atom, no signal, no reactive state container lives inside a component. Every render is a pure function of the current attributes and properties. Debugging a component means inspecting attributes in DevTools — no hidden state to hunt for.
Standards-only. BareDOM relies on Custom Elements v1, Shadow DOM v1, and ES modules — all natively supported in modern browsers. There are no polyfills required and no proprietary APIs to learn.
Zero runtime dependency. Components are compiled to self-contained ES modules. The only JavaScript in your bundle is the component itself. No framework, no runtime library, no utility belt.
Accessible by default. ARIA roles, live regions, keyboard interaction patterns, focus indicators, and prefers-reduced-motion support are written into every component that needs them — not optional add-ons.
Predictable theming. CSS custom properties follow a single naming convention: --x-<component>-<property>. Tokens are set on :host and cascade normally. You override them the same way you override any CSS property.
BareDOM targets browsers that support Custom Elements v1 and Shadow DOM v1 natively:
| Browser | Minimum version |
|---|---|
| Chrome / Edge | 67+ |
| Firefox | 63+ |
| Safari | 14+ |
No polyfills are included or required for these targets.
BareDOM ships with a built-in demo that lets you browse and interact with every component in isolation. It is intended for developer convenience when working on the library itself.
npm install
npx shadow-cljs watch app
Then open http://localhost:8000. The dev server serves public/index.html and hot-reloads on every source change. Each component is demonstrated in its own section with controls for toggling attributes, properties, and variants.
The bare-demo/ folder contains a focused ClojureScript application that shows how to consume five BareDOM components — x-navbar, x-sidebar, x-button, x-modal, and x-container — with zero framework overhead.
The demo is built on three ideas:
renderer.cljs file converts nested ClojureScript vectors into real DOM nodes. There is no virtual DOM, no diffing, and no reactive runtime — just document.createElement, setAttribute, and addEventListener.sidebar-open, modal-open, active-nav) lives in one defonce atom. mount! attaches add-watch so every swap! triggers a full re-render.public/index.html using --x-<component>-<property> rules — no JavaScript involved.Run it:
npx shadow-cljs watch bare-demo
Then open http://localhost:8001.
See bare-demo/README.md for a full walkthrough of the renderer, component registration, view syntax, state management, and theming.
The bare-reagent-demo/ folder is visually identical to bare-demo but replaces the custom hiccup renderer with Reagent — a minimalist ClojureScript wrapper around React. It demonstrates the integration patterns and trade-offs that arise when pairing a virtual-DOM framework with native Custom Elements.
The key differences from bare-demo:
reagent.core/atom drives reactivity. Any component that dereferences a ratom with @ re-renders automatically — no add-watch or manual render trigger required.:on-click) work transparently through React's synthetic event system.x-modal-dismiss, toggle) are not handled by React and must be wired imperatively. The demo uses reagent/create-class lifecycle hooks (component-did-mount / component-will-unmount) and rdom/dom-node to attach and clean up native event listeners on the real DOM element.Run it:
cd bare-reagent-demo
npm install
npm start
Then open http://localhost:8002.
See bare-reagent-demo/README.md for a full code walkthrough, the custom-event workaround, and a side-by-side comparison with bare-demo.
The bare-html/ folder contains the same demo — navbar, sidebar, modal, and event log — written entirely in plain HTML and JavaScript. There is no ClojureScript, no build step, and no bundler. Components are loaded by importing their pre-built ES modules directly from the dist/ folder using a <script type="module"> tag.
The implementation uses three ideas:
setAttribute / removeAttribute from JavaScript.render() function. A simple JS object holds sidebarOpen, modalOpen, and activeNav. The render() function reads this object and reconciles attributes — open/close state is a single setAttribute or removeAttribute call.x-button fires a press event; x-sidebar fires toggle with detail.open; x-modal fires x-modal-dismiss on Escape or backdrop click. All are wired with standard addEventListener.Run it:
# From the project root
python3 -m http.server 8000
Then open http://localhost:8000/bare-html/demo.html. The demo must be served over HTTP — ES module imports are blocked by browsers on file:// URLs.
See bare-html/README.md for a full walkthrough and a comparison with bare-demo.
BareDOM is authored in ClojureScript and compiled with shadow-cljs.
# Install dependencies
npm install
# Start development server with hot reload (http://localhost:8000)
npx shadow-cljs watch app
# Run browser-based tests (http://localhost:8021)
npx shadow-cljs watch test
# Build production ESM library to dist/
npm run build
MIT
Can you improve this documentation?Edit on GitHub
cljdoc builds & hosts documentation for Clojure/Script libraries
| Ctrl+k | Jump to recent docs |
| ← | Move to previous article |
| → | Move to next article |
| Ctrl+/ | Jump to the search field |