Skip to content
Whisq v0.1.0-alpha.9

Element Functions

Every HTML element is a typed function.

function div(props?: CommonProps | Child, ...children: Child[]): WhisqNode
function span(props?: CommonProps | Child, ...children: Child[]): WhisqNode
function button(props?: ButtonProps | Child, ...children: Child[]): WhisqNode
function input(props?: InputProps): WhisqNode
// ... and 30+ more
// With props + children
div({ class: "card", id: "main" }, h1("Title"), p("Content"))
// Without props — just children
div(h1("Title"), p("Content"))
// Single text child
h1("Hello Whisq")

Layout: div, span, main, section, article, aside, header, footer, nav Text: h1h6, p, strong, em, small, pre, code Interactive: button, a Forms: form, input, textarea, select, option, label Lists: ul, ol, li Table: table, thead, tbody, tr, th, td Media: img, video, audio Misc: br, hr, iframe

Pass a function for any prop to make it reactive:

div({ class: () => active.value ? "on" : "off" })
div({ style: () => `color: ${color.value}` })
div({ hidden: () => !visible.value })

class: also accepts an array of sources. Strings are class names; falsy values (false | null | undefined | 0 | "") are filtered out; functions are reactive (re-run when tracked signals change). If any element is a function, the whole array is applied reactively; otherwise it’s applied once at mount.

div({
class: [
"btn", // static
() => `btn-${variant.value}`, // reactive
loading.value && "btn-loading", // static conditional shorthand
() => isDisabled.value && "disabled", // reactive conditional
],
});

Eliminates the cx vs rcx decision for the common case — reach for those only when composing class strings outside an element prop.

The runtime walks the array once and joins the kept sources with a single space. Exact behaviour:

  • Skipped (filtered out): false, null, undefined, 0, "". This is what makes the cond && "active" shorthand work — a false cond short-circuits to the falsy boolean and is dropped.

  • Strings are kept as-is. They are not parsed, trimmed, or split on spaces — "btn primary" is added verbatim, so you can still pack multiple class names into a single string if you want.

  • Truthy non-strings (not functions) are silently dropped. Numbers other than 0, plain objects, arrays, and true are not coerced via String() — they fall through the loop and are not added. The TypeScript type (ClassArraySource) refuses them at the boundary; this rule is the runtime contract for cases that slip through (e.g. any, untyped data).

  • Nested arrays are NOT flattened. A nested array is a non-string non-function value, so it’s dropped rather than recursed into:

    // ❌ "btn" only — the inner array is dropped
    div({ class: ["btn", ["primary", "lg"]] });
    // ✅ Spread the inner array, or flatten it yourself
    div({ class: ["btn", ...["primary", "lg"]] });
  • Functions are called in a reactive getter, and their return value follows the same rules: a truthy string is kept, a falsy value (false | null | undefined | 0 | "") is skipped. A function that returns an array is not flattened — the array ends up joined via its default .toString() (comma-joined), which is almost certainly a bug. Return a pre-joined string instead:

    // ❌ Produces `class="btn a,b"` — Array.toString runs
    div({ class: ["btn", () => active.value ? ["a", "b"] : ""] });
    // ✅ Return a string with spaces — or use separate entries
    div({ class: ["btn", () => active.value && "a b"] });
    div({ class: ["btn", () => active.value && "a", () => active.value && "b"] });

For guide-level treatment (focus management, semantic HTML choices, escape hatch), see the Accessibility guide. The reference below is the prop surface and serialisation rules.

Every element accepts typed aria-* props. Mirrors the data-* pattern but with a wider value type, because ARIA uses both enum-strings (aria-live: "polite") and predicate-booleans (aria-expanded, aria-hidden, aria-pressed). Types are ReactiveProp<string | boolean | undefined> — static values, reactive getters, or undefined to remove the attribute.

// Static
button({ "aria-label": "Remove" }, "×");
// Reactive
button({ "aria-expanded": () => menuOpen.value }, "Menu");
div({ "aria-live": "polite", "aria-atomic": true }, () => status.value);
// Conditional removal
div({ "aria-hidden": () => visible.value ? undefined : true });

Serialisation rules:

ValueSerialised as
"polite" / string"polite"
true"true" (per ARIA spec — fixed in alpha.9)
false"false"
undefined / nullattribute removed

Note: pre-alpha.9, true serialised to the empty string aria-expanded="", which is invalid per the ARIA spec (not equivalent to aria-expanded="true"). Alpha.9 routes aria-* through a dedicated branch in the prop applier to produce the correct string forms.

button({ onclick: () => count.value++ }, "Click")
input({ oninput: (e) => name.value = e.target.value })

Docs current to v0.1.0-alpha.9 . All releases →