Skip to content

Design notes: class: and bind: spec

Status: ✅ Shipped. Both namespaces are implemented in phaze-compile — see Binding & transitions for the user-facing reference. This document is the original implementation spec, kept as the historical design record (the scoping decisions and rejected alternatives below explain why the shipped surface is minimal).

Originally an implementation proposal: both namespaces were reconsidered after initial rejection (see rejected_patterns.md history).

Audience: Contributors and design reviewers. The implementation matches this spec; deviations were recorded in PR descriptions.

Prerequisites:

  • Familiarity with dsl_directive.md (the existing phaze:, on:, use: namespace system)
  • Read access to phaze-compile’s existing visitor structure (Babel-based, ~200 LOC)
  • Understanding of Phaze’s runtime binding helpers (setText, setAttribute, setClass, listen)

Both were rejected initially. The reopening criteria from rejected_patterns.md:

  1. A genuine use case emerges the alternative doesn’t cover
  2. The framework has matured to where the rejection’s reasoning no longer applies
  3. The ecosystem has shifted in ways that change the cost calculus

For class:, the trigger is criterion 1 — when you write components with three or four conditional classes, the phaze:class={...} template-string-with-ternaries form is genuinely worse than the additive class:foo={cond} form. The “without” version in evaluation testing was:

phaze:class={`card${isFav() ? ' fav' : ''}${expanded() ? ' expanded' : ''}${lowStock() ? ' warn' : ''}${isLoading() ? ' loading' : ''}`}

That’s a real maintenance issue. Each space, each ternary, each missing-space bug is a place teams accumulate technical debt. class: solves it for ~15–20 lines of compiler code.

For bind:, the trigger is the minimal-scope insight: shipping only the trivial cases (bind:value on text inputs/textareas, bind:checked on checkboxes) covers the bulk of real-world usage without the implementation surface of the full Svelte-compatible variant. The original rejection assumed all-or-nothing; the minimal scope is a third option that earns its place.


<button
class="btn" // static base class (unchanged behavior)
class:active={isActive()} // conditionally adds 'active' when truthy
class:loading={isLoading()} // conditionally adds 'loading' when truthy
class:disabled={!canSubmit()} // conditionally adds 'disabled' when truthy
>
Submit
</button>

Each class:foo={expr} toggles the presence of class foo on the element based on the truthiness of expr. The expression auto-tracks signals it reads; when those change, the class is added or removed.

  • The class name is the part after class:. class:active means the literal class name active. The compiler reads this from the JSXNamespacedName’s local name property.
  • The value is a thunk-wrapped expression. Coerced to boolean: truthy = class present, falsy = class absent.
  • Composes additively with class="...". The static class attribute sets the initial className; each class: reactively toggles its specific class on top. No interference.
  • Multiple class: attributes are independent. Each gets its own effect, each toggles its own class. Order doesn’t matter.
  • Kebab-case class names work. class:is-loading={...} toggles the class is-loading. JSX namespace local names can contain hyphens.

The compiler sees:

<button class="btn" class:active={isActive()}>

Emits (approximate):

const _el = jsx('button', { class: 'btn' })
effect(() => _el.classList.toggle('active', !!isActive()))

Multiple class: attributes become multiple effects:

<button
class="btn"
class:active={isActive()}
class:loading={isLoading()}
>

Becomes:

const _el = jsx('button', { class: 'btn' })
effect(() => _el.classList.toggle('active', !!isActive()))
effect(() => _el.classList.toggle('loading', !!isLoading()))

The pattern is consistent: one effect per class: directive, each calling classList.toggle with the class name as a string literal and the boolean value.

JSXAttribute(path) {
if (path.node.name.type !== 'JSXNamespacedName') return
if (path.node.name.namespace.name !== 'class') return
const className = path.node.name.name.name // e.g., 'active', 'is-loading'
const value = path.node.value // JSXExpressionContainer or string literal
let valueExpr
if (!value) {
valueExpr = types.booleanLiteral(true) // class:active with no value → always on
} else if (value.type === 'StringLiteral') {
valueExpr = types.booleanLiteral(true) // class:active="anything" treated as always on
} else if (value.type === 'JSXExpressionContainer') {
valueExpr = value.expression
}
// Collect this class: directive for post-element-creation emission
// (mark on the JSXElement to be processed when emitting jsx() call)
markForPostCreation(path.parent, {
type: 'class',
className,
valueExpr,
})
path.remove() // strip the class: attribute itself
}

Then when emitting the JSXElement:

// Wrap jsx() call in IIFE that runs post-creation effects:
((el) => {
effect(() => el.classList.toggle('active', !!isActive()))
effect(() => el.classList.toggle('loading', !!isLoading()))
return el
})(jsx('button', { class: 'btn' }))

Same IIFE pattern used by use: directives.

Combining with phaze:class:

<button phaze:class="btn primary" class:active={isActive()}>

This is supported but discouraged. phaze:class would write the entire className reactively, potentially overwriting class:active’s toggle. The fix is to use static class="btn primary" instead of phaze:class when combining with class: directives. Document this in the user-facing docs; don’t error on it.

Empty / no-value form:

<button class:active>Submit</button>

JSX allows attribute with no value (booleans). Treat as class:active={true} — always on. Useful only in rare cases but should not crash.

Kebab-case class names:

<div class:is-loading={isLoading()}>

The local name is-loading is parsed by Babel as a valid JSXIdentifier in namespace position. The string 'is-loading' is passed to classList.toggle verbatim.

Reserved JS keywords as class names:

<div class:new={isNew()}> // 'new' is a JS keyword
<div class:default={isDefault()}> // 'default' is a JS keyword

These are valid because they’re JSX identifier parts, not JS identifiers. The compiler should pass them through as string literals to classList.toggle. Verify Babel’s parser handles this correctly (it should).

Add these to phaze-compile’s test suite:

// Test 1: Single class: directive
<button class="btn" class:active={cond}>x</button>
// Expected output includes:
// effect(() => el.classList.toggle('active', !!cond))
// Test 2: Multiple class: directives
<div class="card" class:fav={a} class:expanded={b} class:warn={c}>x</div>
// Expected output includes three separate effect() calls
// Test 3: Static class + class: combine
<button class="btn primary" class:active={cond}>x</button>
// Static class 'btn primary' preserved on initial render
// Test 4: Kebab-case class name
<div class:is-loading={cond}>x</div>
// Expected output: classList.toggle('is-loading', !!cond)
// Test 5: Boolean attribute form
<button class:active>x</button>
// Expected output: classList.toggle('active', true)
// Test 6: Signal directly as value
<button class:active={isActive}>x</button> // isActive is a signal
// Expected: effect(() => el.classList.toggle('active', !!isActive()))
// (signal call inside effect for tracking)

Each class: directive adds:

  • ~15 bytes per use site for the inline effect(() => el.classList.toggle('name', !!expr)) call
  • Zero runtime additions (uses existing effect and native classList.toggle)
  • Zero compile-time additions to the bundle (phaze-compile ships zero bytes)

Same per-use cost as use: directives. Negligible across a real app.


Part 2: The bind: namespace (minimal scope)

Section titled “Part 2: The bind: namespace (minimal scope)”

Scope (what’s supported and what’s not)

Section titled “Scope (what’s supported and what’s not)”

Supported:

PatternElement typeEvent
bind:value={signal}<input type="text"> and other text-like inputs (email, password, search, url, tel)input
bind:value={signal}<textarea>input
bind:checked={signal}<input type="checkbox">change

NOT supported (use the manual form):

PatternWhy not supportedManual alternative
bind:value on <input type="number">Needs valueAsNumber coercionvalue={n} on:input={(e) => n.set(+e.currentTarget.value)}
bind:value on <input type="date">Needs Date parsing/formattingvalue={d} on:input={(e) => d.set(new Date(e.currentTarget.value))}
bind:value on <select>Needs property access, <select multiple> is arrayvalue={s} on:change={(e) => s.set(e.currentTarget.value)}
bind:value on <input type="radio">Group semantics; one value across multiple inputsGroup of <input on:change={...} /> reading from same signal
bind:group={signal}Radio button group bindingManual radio pattern
bind:files, bind:open, bind:clientWidth, etc.Specialized bindings with separate semanticsManual property reads

The minimal scope handles the two most common cases (text input, checkbox) which cover ~80% of real-world two-way binding use. Anything else uses the explicit manual form, which never silently breaks.

<input type="text" bind:value={name} />
<textarea bind:value={bio} />
<input type="checkbox" bind:checked={remember} />

The signal flows both ways: signal changes update the input; user input updates the signal.

  • bind:value on text inputs: treats signal() as the current value (pass-through), listens for input event, calls signal.set(e.currentTarget.value) to write back.
  • bind:checked on checkboxes: treats signal() as the current checked state, listens for change event, calls signal.set(e.currentTarget.checked) to write back.
  • The runtime guards against re-entrancy. When the signal is updated from the user input event, the binding’s read side won’t trigger a redundant write back to the DOM because the value is already correct (and the runtime’s setAttribute does an equality check).
  • The value is always a Signal<string> or Signal<boolean>. No automatic coercion. If you have a Signal<number>, use the manual form with explicit + coercion.

For bind:value:

<input type="text" bind:value={name} />

Emits (approximate):

const _el = jsx('input', { type: 'text', value: name }) // signal pass-through for read
listen(_el, 'input', (e) => name.set(e.currentTarget.value)) // write on input event

For bind:checked:

<input type="checkbox" bind:checked={remember} />

Emits:

const _el = jsx('input', { type: 'checkbox', checked: remember })
listen(_el, 'change', (e) => remember.set(e.currentTarget.checked))
JSXAttribute(path) {
if (path.node.name.type !== 'JSXNamespacedName') return
if (path.node.name.namespace.name !== 'bind') return
const property = path.node.name.name.name // 'value' or 'checked'
const value = path.node.value
if (property !== 'value' && property !== 'checked') {
throw path.buildCodeFrameError(
`bind:${property} is not supported. Phaze's bind: namespace handles ` +
`bind:value (text inputs, textarea) and bind:checked (checkboxes) only. ` +
`Use the manual form for other cases: value={signal} on:input={...} or similar.`
)
}
if (!value || value.type !== 'JSXExpressionContainer') {
throw path.buildCodeFrameError(`bind:${property} requires a signal as its value`)
}
const signalExpr = value.expression
const event = property === 'checked' ? 'change' : 'input'
const dotProp = property === 'checked' ? 'checked' : 'value'
// Replace bind: with regular attribute (signal pass-through):
const replacement = types.jsxAttribute(
types.jsxIdentifier(property), // value= or checked=
types.jsxExpressionContainer(signalExpr)
)
path.replaceWith(replacement)
// Mark for post-creation listen() call:
markForPostCreation(path.parent, {
type: 'bind',
event,
dotProp,
signalExpr,
})
}

Then when emitting the JSXElement:

((el) => {
listen(el, 'input', (e) => name.set(e.currentTarget.value))
return el
})(jsx('input', { type: 'text', value: name }))

The compiler should produce clear compile-time errors for unsupported bind:* variants. Don’t silently emit broken code. Examples:

<input type="number" bind:value={age} />
// Error: bind:value on <input type="number"> is not supported.
// Use the manual form with explicit coercion:
// <input type="number" value={age} on:input={(e) => age.set(+e.currentTarget.value)} />

Note: detecting the type attribute requires the compiler to look at sibling attributes on the JSXOpeningElement. This works only for static type="..." literals — if the type is dynamic (type={someSignal}), the compiler can’t validate. In the dynamic case, emit the basic bind:value transform (string-typed) and trust the user knows what they’re doing.

<select bind:value={role} />
// Error: bind:value on <select> is not supported. Use the manual form:
// <select value={role} on:change={(e) => role.set(e.currentTarget.value)}>
<input bind:group={favorite} value="apple" />
// Error: bind:group is not supported. Use a radio button group with manual on:change handlers.

The error messages should be specific, point at the manual alternative, and not just say “unsupported.” Save the user the trip to docs.

// Test 1: bind:value on text input
<input type="text" bind:value={name} />
// Expected output:
// const el = jsx('input', { type: 'text', value: name })
// listen(el, 'input', (e) => name.set(e.currentTarget.value))
// Test 2: bind:value on textarea
<textarea bind:value={bio} />
// Expected: same shape as text input
// Test 3: bind:checked on checkbox
<input type="checkbox" bind:checked={remember} />
// Expected output:
// const el = jsx('input', { type: 'checkbox', checked: remember })
// listen(el, 'change', (e) => remember.set(e.currentTarget.checked))
// Test 4: bind:value on number input — should error
<input type="number" bind:value={age} />
// Expected: compile error with message pointing at manual form
// Test 5: bind:value on select — should error
<select bind:value={role}><option>a</option></select>
// Expected: compile error
// Test 6: bind:group — should error
<input type="radio" bind:group={favorite} />
// Expected: compile error
// Test 7: dynamic type (warning, not error)
<input type={inputType} bind:value={text} />
// Expected: basic transform emitted (string handling), no error
// Possibly emit a console.warn at runtime via the compiler? Or just trust the user.
// Test 8: Combining bind:value with on:input — caller wants explicit access too
<input bind:value={text} on:input={(e) => console.log(e.currentTarget.value)} />
// Expected: both wire up; both listeners fire on input.
// Order: framework listener (from bind:) fires, then user's on:input.

Each bind: directive adds:

  • ~25 bytes per use site for the listen() call (slightly larger than class: because it includes the event name string and the handler closure)
  • Zero new runtime functions (uses existing listen from main phaze)
  • Zero compile-time bundle additions

Same order of magnitude as class:. Negligible.


Part 3: Integration with existing namespaces

Section titled “Part 3: Integration with existing namespaces”

The four-namespace system after this lands:

NamespaceJobStatus
phaze:attrReactive attribute expressionsProposed (in dsl_directive.md)
on:eventNative DOM event listenersProposed (in dsl_directive.md)
use:directiveDOM behavior attachmentsProposed (in dsl_directive.md)
class:nameConditional class additionNew (this doc)
bind:value, bind:checkedTwo-way input binding (minimal scope)New (this doc)

All five share the same architectural model:

  • Compile-time recognition (Babel plugin scoped to specific namespace prefixes)
  • Per-namespace visitor logic
  • Zero runtime additions
  • Zero bundle delta vs hand-written equivalents

The compiler implementation should reuse the same markForPostCreation mechanism (or equivalent) across use:, class:, and bind: — all three emit code that runs after element creation.

When an element has multiple post-creation directives (use:, class:, bind:), they should fire in source order:

<input
type="text"
class="input"
use:autofocus={true}
class:invalid={!valid()}
bind:value={text}
/>

Should emit:

((el) => {
autofocus(el, () => true)
effect(() => el.classList.toggle('invalid', !valid()))
listen(el, 'input', (e) => text.set(e.currentTarget.value))
return el
})(jsx('input', { type: 'text', class: 'input', value: text }))

Source order. Don’t get clever about reordering.


Add to the JSX Namespaces table in Part 3:

NamespacePurposeCompiler behavior
class:nameConditional class additionEmits effect(() => el.classList.toggle('name', !!expr)) post-creation
bind:value / bind:checkedTwo-way input binding (minimal scope: text inputs, textarea, checkbox only)Emits signal pass-through + listen() post-creation

Add a brief section after use: documenting both, with examples.

Move section 5 (bind: JSX namespace) and remove the implicit-rejection language for class: (which wasn’t explicitly in the doc but the spirit was that template-string conditionals were the answer).

Update section 5 to read something like:

Originally rejected. The full Svelte-compatible bind: would require covering many input types (number, date, select, radio, file, etc.) each with their own coercion and event semantics. The original rejection assumed all-or-nothing.

Reconsidered with minimal scope. Shipping only bind:value (text inputs, textarea) and bind:checked (checkboxes) covers the bulk of real usage. Type-fiddly variants stay manual. See class_bind_implementation.md for the spec.

Still rejected: the full Svelte-compatible variant, bind:group, bind:files, bind:clientWidth, and any “implicit” two-way binding outside the two supported cases above. Use manual form for those.

Add a similar entry noting class: was rejected by implication (the template-string-conditional pattern was treated as sufficient) but is now planned.


For the implementer (Claude Code or human):

  • Add JSXAttribute visitor branch for class: namespace
  • Add JSXAttribute visitor branch for bind: namespace
  • Implement post-creation IIFE wrapping (or reuse from use: if shipped)
  • Add compile-time validation for unsupported bind: variants with helpful error messages
  • Add unit tests covering all test cases above
  • None. Both namespaces use existing effect, listen, classList.toggle. Verify no runtime additions slip in.
  • Update dsl_directive.md Part 3 with new namespaces
  • Update rejected_patterns.md section 5 with reconsideration note
  • Add user-facing docs section (probably in the main docs site) showing the new patterns
  • Update the Card build test in dsl_directive.md to use class: for at least one example (showing real-world usage)
  • Compile the Card build test from dsl_directive.md with the changes
  • Verify bundle size hasn’t grown beyond expected (~15 bytes per class:, ~25 bytes per bind: use site)
  • Verify runtime behavior end-to-end: typing in input updates signal, signal updates propagate to input, conditional classes toggle correctly
  • Verify compile errors fire for bind:value on <input type="number">, <select>, etc.
  • Full Svelte-compatible bind:value (number, date, file, etc.)
  • bind:group for radios
  • bind:clientWidth, bind:innerHTML, and other dimensional/property bindings
  • A configurable option for “enable full bind: at user’s risk” — keep the minimal scope strict to avoid feature creep

For docs and PR description, here’s the before/after comparison that justifies the work:

Multiple conditional classes — without class::

<article
phaze:class={
`card${isFav() ? ' fav' : ''}${expanded() ? ' expanded' : ''}${lowStock() ? ' warn' : ''}${isLoading() ? ' loading' : ''}`
}
>

With class::

<article
class="card"
class:fav={isFav()}
class:expanded={expanded()}
class:warn={lowStock()}
class:loading={isLoading()}
>

Two-way input binding — without bind::

<input
type="text"
value={name}
on:input={(e) => name.set(e.currentTarget.value)}
/>
<input
type="checkbox"
checked={remember}
on:change={(e) => remember.set(e.currentTarget.checked)}
/>

With bind: (minimal scope):

<input type="text" bind:value={name} />
<input type="checkbox" bind:checked={remember} />

Same end behavior in both cases. The class: form removes template-string fragility; the bind: form removes 1 line per input.


These additions don’t replace the existing namespaces. phaze:class={...} remains the way to set an entirely conditional class string (e.g., phaze:class={theme()}). value={signal} on:input={...} remains the way to handle non-text-input two-way binding (number, date, select). The new namespaces are additions for specific common cases, not replacements for the more general forms.

Total compiler additions: ~40 lines for both namespaces combined. Zero runtime additions. Zero bundle delta for non-users. The work is small; the ergonomic win is concentrated in exactly the patterns where Phaze code currently feels clunkiest.