Client-side web components compiled via embedded Squint.
This namespace provides the foundation for defining custom elements in a ClojureScript dialect (Squint) that are compiled to JavaScript at the JVM (no Node build step) and served as a single ES module bundle at /hyper/components.js.
The model follows Datastar's recommended escape hatch for rich client-side islands — web components with "props down, events up":
data-on:* +
h/action (see the $detail client param).The primary API is the defc macro, which compiles the component's client
behaviour to JS at macro-expansion time and emits a server-side render fn
so call sites look like ordinary hiccup functions. register-component!
is the lower-level escape hatch for cases where raw Squint strings are
needed.
Related namespaces: hyper.component.hiccup (macro-time hiccup -> ($h ...) descriptor compilation) and hyper.component.bundle (ES module assembly and the head script tag).
Client-side web components compiled via embedded Squint. This namespace provides the foundation for defining custom elements in a ClojureScript dialect (Squint) that are compiled to JavaScript at the JVM (no Node build step) and served as a single ES module bundle at /hyper/components.js. The model follows Datastar's recommended escape hatch for rich client-side islands — web components with "props down, events up": - **Attributes are the boundary.** The server renders component data into HTML attributes (deterministic JSON for collections). Datastar's morph updates attributes in place, and the client runtime re-renders only when an attribute string actually changed. - **Events are the channel out.** Components dispatch bubbling, composed CustomEvents that the server catches with ordinary `data-on:*` + `h/action` (see the `$detail` client param). The primary API is the `defc` macro, which compiles the component's client behaviour to JS at macro-expansion time and emits a server-side render fn so call sites look like ordinary hiccup functions. `register-component!` is the lower-level escape hatch for cases where raw Squint strings are needed. Related namespaces: hyper.component.hiccup (macro-time hiccup -> ($h ...) descriptor compilation) and hyper.component.bundle (ES module assembly and the head script tag).
(attr-value v)Serialize a single component attribute value for HTML. Strings pass through raw (human-readable attributes); collections get deterministic JSON; keywords serialize as their name.
Serialize a single component attribute value for HTML. Strings pass through raw (human-readable attributes); collections get deterministic JSON; keywords serialize as their name.
(attrs m)Serialize a map of component attribute values for use in hiccup.
Component data attrs are serialized via attr-value. Keys that are
part of the host-element contract — data-* (e.g. data-on:* action
bindings), :id, :class, :style — pass through untouched.
Passing a signal object (un-deref'd) as an attribute value creates a live two-way client-side link: Datastar keeps the attribute synced to the signal (component re-renders/updates on signal change with no server round-trip), and the component writes the signal back by emitting an event named after the attribute:
;; server render fn (let [hover* (h/signal :hovered-symbol nil)] (stock-chart {:points @points* :hover hover*}))
;; inside the component: read hover like any attr; write it with
(emit "hover" "AAPL")
Example: [:temp-gauge (hc/attrs {:value @temp* :label "CPU" :data-on:gauge-selected (h/action ...)})]
Serialize a map of component attribute values for use in hiccup.
Component data attrs are serialized via `attr-value`. Keys that are
part of the host-element contract — `data-*` (e.g. `data-on:*` action
bindings), :id, :class, :style — pass through untouched.
Passing a **signal object** (un-deref'd) as an attribute value creates a
live two-way client-side link: Datastar keeps the attribute synced to
the signal (component re-renders/updates on signal change with no server
round-trip), and the component writes the signal back by emitting an
event named after the attribute:
;; server render fn
(let [hover* (h/signal :hovered-symbol nil)]
(stock-chart {:points @points* :hover hover*}))
;; inside the component: read `hover` like any attr; write it with
(emit "hover" "AAPL")
Example:
[:temp-gauge (hc/attrs {:value @temp*
:label "CPU"
:data-on:gauge-selected (h/action ...)})](check-alias-conflicts! tag-name requires)Throw when any of requires uses an alias already registered by another
component with a different URL — the bundle is a single module scope, so
aliases must map 1:1 to URLs across all components.
Public because defc expansions call it from consumer namespaces.
Throw when any of `requires` uses an alias already registered by another component with a different URL — the bundle is a single module scope, so aliases must map 1:1 to URLs across all components. Public because defc expansions call it from consumer namespaces.
(compile-squint source)Compile a string of Squint (CLJS-dialect) source to JavaScript.
Imports are elided and squint.core references are emitted against the
$sc alias, which the assembled bundle binds via a single import of
squint's core.js. Returns the JS string.
Throws ex-info with the source attached when compilation fails.
Compile a string of Squint (CLJS-dialect) source to JavaScript. Imports are elided and squint.core references are emitted against the `$sc` alias, which the assembled bundle binds via a single import of squint's core.js. Returns the JS string. Throws ex-info with the source attached when compilation fails.
(defc cname & body)Define a client-side web component.
Compiles the component's client behaviour to JavaScript via Squint at macro-expansion time and registers it in the global component registry. Also emits a server-side render function (with the same name) that serializes attributes and returns the correct hiccup host element, so call sites look like ordinary Clojure functions.
Syntax: (defc component-name "Optional docstring" {:require [["url" :as alias] ...]} ; optional ES module dependencies [{:keys [attr1 attr2 ...]}] ; observed attributes — must be :keys form (event ::handler-name [e] ; zero or more named event handlers ...) (render ; hiccup for the shadow root hiccup-returning-body) (mount [root] ...) ; optional: runs once after first render (update [root old-attrs] ...) ; optional: runs on attr changes instead ; of re-rendering (seamless mode) (unmount [root] ...)) ; optional: cleanup on real DOM removal
Two update models:
Declarative (no update segment): render re-runs whenever an
attribute actually changes.
Seamless (update present): render is a once-only scaffold (or
omit it for a bare root); mount initializes a JS library against the
root element; update receives each data change so the library can
transition in place — the DOM is never re-rendered, so chart instances
and animations survive arbitrary server re-renders. Morph-driven DOM
moves are debounced and do NOT unmount; only real removal does.
In every segment, ctx is in scope: a stable per-instance JS object
carrying emit, and usable as the instance state slot:
(mount [root] (set! (.-chart ctx) (init-chart! root data))) (update [root] (redraw! (.-chart ctx) data)) (unmount [root] (destroy! (.-chart ctx)))
:require declares ES module dependencies, available in all segments via
the alias (e.g. (d3/line)). Each URL is imported once in the bundle,
deduplicated across components; an alias may map to only one URL across
the whole app:
(defc stock-chart {:require [["https://esm.sh/d3@7" :as d3]]} [{:keys [points]}] (render [:svg (d3-path points)]))
Inside render, reference event handlers in :on maps using
namespace-qualified keywords matching the event names:
{:on {:click ::handler-name}}
Inside event bodies, emit is available as a function:
(emit "event-name" detail-map)
The emitted server-side function accepts a map argument followed by any
number of child elements. Pass your attribute values (including
data-on:* action bindings) in the map; children are appended to the
host element's light DOM (projected through any <slot> the component
renders):
(my-component {:value @cursor* :label "CPU" :data-on:selected (h/action (handle! $detail))})
(my-component {:label "CPU"} [:span "slotted child"] [:span "another"])
Example: (defc temp-gauge [{:keys [value max label]}] (event ::selected [_e] (emit "gauge-selected" {:value value :label label})) (render (let [pct (js/Math.round (* 100 (/ value max)))] [:div {:on {:click ::selected}} [:span label ": " pct "%"]])))
Define a client-side web component.
Compiles the component's client behaviour to JavaScript via Squint at
macro-expansion time and registers it in the global component registry.
Also emits a server-side render function (with the same name) that
serializes attributes and returns the correct hiccup host element,
so call sites look like ordinary Clojure functions.
Syntax:
(defc component-name
"Optional docstring"
{:require [["url" :as alias] ...]} ; optional ES module dependencies
[{:keys [attr1 attr2 ...]}] ; observed attributes — must be :keys form
(event ::handler-name [e] ; zero or more named event handlers
...)
(render ; hiccup for the shadow root
hiccup-returning-body)
(mount [root] ...) ; optional: runs once after first render
(update [root old-attrs] ...) ; optional: runs on attr changes instead
; of re-rendering (seamless mode)
(unmount [root] ...)) ; optional: cleanup on real DOM removal
Two update models:
- **Declarative** (no `update` segment): `render` re-runs whenever an
attribute actually changes.
- **Seamless** (`update` present): `render` is a once-only scaffold (or
omit it for a bare root); `mount` initializes a JS library against the
root element; `update` receives each data change so the library can
transition in place — the DOM is never re-rendered, so chart instances
and animations survive arbitrary server re-renders. Morph-driven DOM
moves are debounced and do NOT unmount; only real removal does.
In every segment, `ctx` is in scope: a stable per-instance JS object
carrying `emit`, and usable as the instance state slot:
(mount [root] (set! (.-chart ctx) (init-chart! root data)))
(update [root] (redraw! (.-chart ctx) data))
(unmount [root] (destroy! (.-chart ctx)))
`:require` declares ES module dependencies, available in all segments via
the alias (e.g. `(d3/line)`). Each URL is imported once in the bundle,
deduplicated across components; an alias may map to only one URL across
the whole app:
(defc stock-chart
{:require [["https://esm.sh/d3@7" :as d3]]}
[{:keys [points]}]
(render [:svg (d3-path points)]))
Inside `render`, reference event handlers in `:on` maps using
namespace-qualified keywords matching the `event` names:
{:on {:click ::handler-name}}
Inside `event` bodies, `emit` is available as a function:
(emit "event-name" detail-map)
The emitted server-side function accepts a map argument followed by any
number of child elements. Pass your attribute values (including
`data-on:*` action bindings) in the map; children are appended to the
host element's light DOM (projected through any `<slot>` the component
renders):
(my-component {:value @cursor*
:label "CPU"
:data-on:selected (h/action (handle! $detail))})
(my-component {:label "CPU"}
[:span "slotted child"]
[:span "another"])
Example:
(defc temp-gauge
[{:keys [value max label]}]
(event ::selected [_e]
(emit "gauge-selected" {:value value :label label}))
(render
(let [pct (js/Math.round (* 100 (/ value max)))]
[:div {:on {:click ::selected}}
[:span label ": " pct "%"]])))(extract-attrs binding-vec)Extract the vector of observed attribute name keywords from a defc
parameter vector of the form [{:keys [attr1 attr2 ...]}].
The first element of the vector must be a map-destructuring form
containing :keys. Attribute names must be statically visible at
macro-expansion time so observedAttributes can be declared.
Returns a vector of keywords, e.g. [:value :max :label].
Extract the vector of observed attribute name keywords from a `defc`
parameter vector of the form `[{:keys [attr1 attr2 ...]}]`.
The first element of the vector must be a map-destructuring form
containing `:keys`. Attribute names must be statically visible at
macro-expansion time so `observedAttributes` can be declared.
Returns a vector of keywords, e.g. [:value :max :label].(parse-requires requires)Normalize and validate a :require option value.
Accepts a vector of ["url" :as alias] entries and returns a vector of {:url "..." :alias sym} maps. Throws on malformed entries.
Normalize and validate a :require option value.
Accepts a vector of ["url" :as alias] entries and returns a vector of
{:url "..." :alias sym} maps. Throws on malformed entries.(register-component! name spec)Compile and register a client-side component.
name — custom element tag name string (must contain a hyphen, per the
Custom Elements spec, e.g. "temp-gauge").
spec — map with:
:attrs — vector of observed attribute names (strings or keywords)
:render — Squint source string for a (fn [props ctx] hiccup) form.
props is a JS object keyed by attribute name with values
parsed from the attributes (JSON for collections, raw for
plain strings). ctx provides:
(.-emit ctx) — (fn [event-name detail]) dispatch a bubbling,
composed CustomEvent across the boundary.
:require — optional vector of ES module deps: [["url" :as alias] ...].
Alias-qualified symbols (alias/fn) resolve in the source and
the bundle imports each URL once.
Compilation happens immediately (at the JVM); errors surface at definition time, not in the browser. Re-registering a name replaces the spec — connected tabs watching the registry receive the new bundle over SSE and live instances re-render.
Returns the component name.
Compile and register a client-side component.
name — custom element tag name string (must contain a hyphen, per the
Custom Elements spec, e.g. "temp-gauge").
spec — map with:
:attrs — vector of observed attribute names (strings or keywords)
:render — Squint source string for a `(fn [props ctx] hiccup)` form.
`props` is a JS object keyed by attribute name with values
parsed from the attributes (JSON for collections, raw for
plain strings). `ctx` provides:
(.-emit ctx) — (fn [event-name detail]) dispatch a bubbling,
composed CustomEvent across the boundary.
:require — optional vector of ES module deps: [["url" :as alias] ...].
Alias-qualified symbols (alias/fn) resolve in the source and
the bundle imports each URL once.
Compilation happens immediately (at the JVM); errors surface at
definition time, not in the browser. Re-registering a name replaces
the spec — connected tabs watching the registry receive the new bundle
over SSE and live instances re-render.
Returns the component name.Global component registry: name -> {:attrs [...] :js "..."}. Components are code (like defn), not per-app state, so the registry is global. Watched by connected tabs so REPL redefinition hot-swaps the bundle over SSE.
Global component registry: name -> {:attrs [...] :js "..."}.
Components are code (like defn), not per-app state, so the registry is
global. Watched by connected tabs so REPL redefinition hot-swaps the
bundle over SSE.Deterministic JSON encoding of a Clojure value, LRU-memoized so large unchanged data structures (e.g. chart datasets) serialize once across re-renders rather than per frame.
Deterministic JSON encoding of a Clojure value, LRU-memoized so large unchanged data structures (e.g. chart datasets) serialize once across re-renders rather than per frame.
(sym->tag sym)Convert a Clojure symbol to a custom-element tag name string. CamelCase becomes kebab-case; underscores become hyphens. The name must contain a hyphen (Custom Elements spec requirement).
Examples: temp-gauge -> "temp-gauge" TempGauge -> "temp-gauge" temp_gauge -> "temp-gauge"
Convert a Clojure symbol to a custom-element tag name string. CamelCase becomes kebab-case; underscores become hyphens. The name must contain a hyphen (Custom Elements spec requirement). Examples: temp-gauge -> "temp-gauge" TempGauge -> "temp-gauge" temp_gauge -> "temp-gauge"
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 |