Skip to content

Counter

A minimal two-way-bound counter. Demonstrates <props> with model: true, <data> for local reactive state, $computed, and @event handlers in <template>.

Live demo

The Counter below is the actual examples/Counter.rozie file from the monorepo, compiled by @rozie/unplugin/vite at build time into a Vue SFC and rendered inline. Click the buttons; the value is two-way-bound to local state on this page.

Current value: 2

Source — Counter.rozie

rozie
<!--
  Counter.rozie

  Demonstrates the basics:
    - <props> with `model: true` for two-way binding
    - <data> with local reactive state
    - <script> with $props, $data, $computed, methods as plain functions
    - <template> with @event, :prop, {{ }} interpolation
    - Always-scoped <style>

  No lifecycle, no refs, no listeners — see Dropdown.rozie for those.
-->

<rozie name="Counter">

<props>
{
  value: { type: Number, default: 0,  model: true },
  step:  { type: Number, default: 1 },
  min:   { type: Number, default: -Infinity },
  max:   { type: Number, default: Infinity },
}
</props>

<data>
{
  hovering: false,
}
</data>

<script>
console.log("hello from rozie")

const canIncrement = $computed(() => $props.value + $props.step <= $props.max)
const canDecrement = $computed(() => $props.value - $props.step >= $props.min)

const increment = () => { if (canIncrement) $model.value += $props.step }
const decrement = () => { if (canDecrement) $model.value -= $props.step }
</script>

<template>
<div
  class="counter"
  :class="{ hovering: $data.hovering }"
  @mouseenter="$data.hovering = true"
  @mouseleave="$data.hovering = false"
>
  <button :disabled="!canDecrement" @click="decrement" aria-label="Decrement">−</button>
  <span class="value">{{ $props.value }}</span>
  <button :disabled="!canIncrement" @click="increment" aria-label="Increment">+</button>
</div>
</template>

<style>
.counter { display: inline-flex; gap: 0.5rem; align-items: center; }
.counter.hovering { background: rgba(0, 0, 0, 0.04); }
.value { font-variant-numeric: tabular-nums; min-width: 3ch; text-align: center; }
button { padding: 0.25rem 0.5rem; }
button:disabled { opacity: 0.4; cursor: not-allowed; }
</style>

</rozie>

Compiled output

vue
<template>

<div :class="['counter', { hovering: hovering }]" v-bind="$attrs" @mouseenter="hovering = true" @mouseleave="hovering = false">
  <button :disabled="!canDecrement" aria-label="Decrement" @click="decrement">−</button>
  <span class="value">{{ value }}</span>
  <button :disabled="!canIncrement" aria-label="Increment" @click="increment">+</button>
</div>

</template>

<script setup lang="ts">
import { computed, ref } from 'vue';

const props = withDefaults(
  defineProps<{ step?: number; min?: number; max?: number }>(),
  { step: 1, min: -Infinity, max: Infinity }
);

const value = defineModel<number>('value', { default: 0 });

const hovering = ref(false);

const canIncrement = computed(() => value.value + props.step <= props.max);
const canDecrement = computed(() => value.value - props.step >= props.min);

console.log("hello from rozie");
const increment = () => {
  if (canIncrement.value) value.value += props.step;
};
const decrement = () => {
  if (canDecrement.value) value.value -= props.step;
};
</script>

<style scoped>
.counter { display: inline-flex; gap: 0.5rem; align-items: center; }
.counter.hovering { background: rgba(0, 0, 0, 0.04); }
.value { font-variant-numeric: tabular-nums; min-width: 3ch; text-align: center; }
button { padding: 0.25rem 0.5rem; }
button:disabled { opacity: 0.4; cursor: not-allowed; }
</style>
tsx
import { useCallback, useMemo, useState } from 'react';
import { clsx, useControllableState } from '@rozie/runtime-react';
import './Counter.css';

interface CounterProps {
  value?: number;
  defaultValue?: number;
  onValueChange?: (value: number) => void;
  step?: number;
  min?: number;
  max?: number;
}

export default function Counter(_props: CounterProps): JSX.Element {
  const props: Omit<CounterProps, 'step' | 'min' | 'max'> & { step: number; min: number; max: number } = {
    ..._props,
    step: _props.step ?? 1,
    min: _props.min ?? -Infinity,
    max: _props.max ?? Infinity,
  };
  const attrs: Record<string, unknown> = (() => {
    const { value, step, min, max, defaultValue, onValueChange, ...rest } = _props as CounterProps & Record<string, unknown>;
    void value; void step; void min; void max; void defaultValue; void onValueChange;
    return rest;
  })();
  const [value, setValue] = useControllableState({
    value: props.value,
    defaultValue: props.defaultValue ?? 0,
    onValueChange: props.onValueChange,
  });
  const [hovering, setHovering] = useState(false);
  const canIncrement = useMemo(() => value + props.step <= props.max, [props.max, props.step, value]);
  const canDecrement = useMemo(() => value - props.step >= props.min, [props.min, props.step, value]);

  console.log("hello from rozie");
  const increment = useCallback(() => {
    if (canIncrement) setValue(prev => prev + props.step);
  }, [canIncrement, props.step, setValue]);
  const decrement = useCallback(() => {
    if (canDecrement) setValue(prev => prev - props.step);
  }, [canDecrement, props.step, setValue]);

  return (
    <>
    <div {...attrs} className={clsx(clsx("counter", { hovering: hovering }), (attrs.className as string | undefined))} onMouseEnter={($event) => { setHovering(true); }} onMouseLeave={($event) => { setHovering(false); }} data-rozie-s-c72e01d0="">
      <button disabled={!canDecrement} aria-label="Decrement" onClick={decrement} data-rozie-s-c72e01d0="">−</button>
      <span className={"value"} data-rozie-s-c72e01d0="">{value}</span>
      <button disabled={!canIncrement} aria-label="Increment" onClick={increment} data-rozie-s-c72e01d0="">+</button>
    </div>
    </>
  );
}
svelte
<script lang="ts">
import { applyListeners } from '@rozie/runtime-svelte';

interface Props {
  value?: number;
  step?: number;
  min?: number;
  max?: number;
  [key: string]: unknown;
}

let {
  value = $bindable(0),
  step = 1,
  min = -Infinity,
  max = Infinity,
  ...__rozieAttrs
}: Props = $props();

let hovering = $state(false);

console.log("hello from rozie");
const increment = () => {
  if (canIncrement) value += step;
};
const decrement = () => {
  if (canDecrement) value -= step;
};

const canIncrement = $derived(value + step <= max);
const canDecrement = $derived(value - step >= min);
</script>

<div {...__rozieAttrs} class={["counter", { hovering: hovering }, (__rozieAttrs)?.class]} onmouseenter={($event) => { hovering = true; }} onmouseleave={($event) => { hovering = false; }} use:applyListeners={__rozieAttrs} data-rozie-s-c72e01d0><button disabled={!canDecrement} aria-label="Decrement" onclick={decrement} data-rozie-s-c72e01d0>−</button><span class="value" data-rozie-s-c72e01d0>{value}</span><button disabled={!canIncrement} aria-label="Increment" onclick={increment} data-rozie-s-c72e01d0>+</button></div>

<style>
:global {
  .counter[data-rozie-s-c72e01d0] { display: inline-flex; gap: 0.5rem; align-items: center; }
  .counter.hovering[data-rozie-s-c72e01d0] { background: rgba(0, 0, 0, 0.04); }
  .value[data-rozie-s-c72e01d0] { font-variant-numeric: tabular-nums; min-width: 3ch; text-align: center; }
  button[data-rozie-s-c72e01d0] { padding: 0.25rem 0.5rem; }
  button[data-rozie-s-c72e01d0]:disabled { opacity: 0.4; cursor: not-allowed; }
}
</style>
ts
import { Component, DestroyRef, ElementRef, Renderer2, ViewEncapsulation, afterRenderEffect, computed, effect, forwardRef, inject, input, model, signal, viewChild } from '@angular/core';
import { NgClass } from '@angular/common';
import { NG_VALUE_ACCESSOR } from '@angular/forms';

@Component({
  selector: 'rozie-counter',
  standalone: true,
  imports: [NgClass],
  template: `

    <div class="counter" [ngClass]="{ hovering: hovering() }" #rozieSpread_0 (mouseenter)="hovering.set(true)" (mouseleave)="hovering.set(false)" #rozieListenersTarget_1>
      <button [disabled]="!canDecrement()" aria-label="Decrement" (click)="decrement()">−</button>
      <span class="value">{{ value() }}</span>
      <button [disabled]="!canIncrement()" aria-label="Increment" (click)="increment()">+</button>
    </div>

  `,
  styles: [`
    .counter { display: inline-flex; gap: 0.5rem; align-items: center; }
    .counter.hovering { background: rgba(0, 0, 0, 0.04); }
    .value { font-variant-numeric: tabular-nums; min-width: 3ch; text-align: center; }
    button { padding: 0.25rem 0.5rem; }
    button:disabled { opacity: 0.4; cursor: not-allowed; }
  `],
  providers: [
    {
      provide: NG_VALUE_ACCESSOR,
      useExisting: forwardRef(() => Counter),
      multi: true,
    },
  ],
  host: { '(focusout)': '__rozieCvaOnTouched()' },
})
export class Counter {
  value = model<number>(0);
  step = input<number>(1);
  min = input<number>(-Infinity);
  max = input<number>(Infinity);
  hovering = signal(false);

  constructor() {
    console.log("hello from rozie");
  }

  canIncrement = computed(() => this.value() + this.step() <= this.max());
  canDecrement = computed(() => this.value() - this.step() >= this.min());

  increment = () => {
    if (this.canIncrement()) this.value.set(this.value() + this.step()), this.__rozieCvaOnChange(this.value() + this.step());
  };
  decrement = () => {
    if (this.canDecrement()) this.value.set(this.value() - this.step()), this.__rozieCvaOnChange(this.value() - this.step());
  };

  private __rozieCvaOnChange: (v: number) => void = () => {};
  private __rozieCvaOnTouchedFn: () => void = () => {};
  protected __rozieCvaDisabled = signal(false);

  writeValue(v: number | null): void {
    this.value.set(v ?? 0);
  }
  registerOnChange(fn: (v: number) => void): void {
    this.__rozieCvaOnChange = fn;
  }
  registerOnTouched(fn: () => void): void {
    this.__rozieCvaOnTouchedFn = fn;
  }
  setDisabledState(isDisabled: boolean): void {
    this.__rozieCvaDisabled.set(isDisabled);
  }
  __rozieCvaOnTouched(): void {
    this.__rozieCvaOnTouchedFn();
  }

  private __rozieDestroyRef = inject(DestroyRef);

  private rozieSpread_0 = viewChild<ElementRef>('rozieSpread_0');

  private __rozieApplyAttrs = (() => {
    const renderer = inject(Renderer2);
    const prevKeysByElement = new WeakMap<HTMLElement, string[]>();
    const prevClassTokensByElement = new WeakMap<HTMLElement, string[]>();
    const prevStylePropsByElement = new WeakMap<HTMLElement, string[]>();
    const parseClassTokens = (value: unknown): string[] => {
      if (typeof value !== 'string') return [];
      const out: string[] = [];
      for (const tok of value.split(/\s+/)) {
        if (tok.length > 0) out.push(tok);
      }
      return out;
    };
    const parseStyleDecls = (value: unknown): Array<[string, string]> => {
      if (typeof value !== 'string') return [];
      const out: Array<[string, string]> = [];
      for (const decl of value.split(';')) {
        const colon = decl.indexOf(':');
        if (colon < 0) continue;
        const prop = decl.slice(0, colon).trim();
        const val = decl.slice(colon + 1).trim();
        if (prop.length > 0) out.push([prop, val]);
      }
      return out;
    };
    const applyClassMerge = (el: HTMLElement, value: unknown) => {
      const next = parseClassTokens(value);
      const prev = prevClassTokensByElement.get(el) ?? [];
      const nextSet = new Set(next);
      for (const tok of prev) {
        if (!nextSet.has(tok)) el.classList.remove(tok);
      }
      for (const tok of next) el.classList.add(tok);
      prevClassTokensByElement.set(el, next);
    };
    const applyStyleMerge = (el: HTMLElement, value: unknown) => {
      const next = parseStyleDecls(value);
      const prev = prevStylePropsByElement.get(el) ?? [];
      const nextProps = next.map(([p]) => p);
      const nextSet = new Set(nextProps);
      for (const prop of prev) {
        if (!nextSet.has(prop)) el.style.removeProperty(prop);
      }
      for (const [prop, val] of next) el.style.setProperty(prop, val, 'important');
      prevStylePropsByElement.set(el, nextProps);
    };
    return (el: HTMLElement, obj: Record<string, unknown> | null | undefined) => {
      const safeObj: Record<string, unknown> = obj ?? {};
      const prevKeys = prevKeysByElement.get(el) ?? [];
      for (const k of prevKeys) {
        if (k === 'class' || k === 'style') continue;
        if (!(k in safeObj)) renderer.removeAttribute(el, k);
      }
      if (!('class' in safeObj) && prevClassTokensByElement.has(el)) {
        applyClassMerge(el, '');
      }
      if (!('style' in safeObj) && prevStylePropsByElement.has(el)) {
        applyStyleMerge(el, '');
      }
      for (const [k, v] of Object.entries(safeObj)) {
        if (k === 'class') {
          applyClassMerge(el, v);
        } else if (k === 'style') {
          applyStyleMerge(el, v);
        } else if (v === null || v === false) {
          renderer.removeAttribute(el, k);
        } else {
          renderer.setAttribute(el, k, String(v));
        }
      }
      prevKeysByElement.set(el, Object.keys(safeObj));
    };
  })();

  private __rozieGetHostAttrs = (() => {
    const host = inject(ElementRef);
    return () => {
      const el = host.nativeElement as HTMLElement;
      const out: Record<string, unknown> = {};
      for (const a of Array.from(el.attributes)) out[a.name] = a.value;
      return out;
    };
  })();

  private __rozieSpread_0_effect = afterRenderEffect(() => {
    const el = this.rozieSpread_0()?.nativeElement;
    if (!el) return;
    this.__rozieApplyAttrs(el, this.__rozieGetHostAttrs());
  });

  private rozieListenersTarget_1 = viewChild<ElementRef>('rozieListenersTarget_1');

  private __rozieListenersRenderer = inject(Renderer2);

  private __rozieListenersDisposers_1: Array<() => void> = [];

  private __rozieListenersDestroyRegistered_1 = false;

  private __rozieListenersEffect_1 = effect(() => {
    const el = this.rozieListenersTarget_1()?.nativeElement;
    if (!el) return;
    for (const off of this.__rozieListenersDisposers_1) off();
    this.__rozieListenersDisposers_1 = [];
    const obj: Record<string, unknown> = {};
    for (const [k, v] of Object.entries(obj)) {
      if (k === '__proto__' || k === 'constructor' || k === 'prototype') continue;
      if (typeof v !== 'function') continue;
      const norm = k.startsWith('on') ? k.slice(2).toLowerCase() : k;
      const dispose = this.__rozieListenersRenderer.listen(el, norm, v as EventListener);
      this.__rozieListenersDisposers_1.push(dispose);
    }
    if (!this.__rozieListenersDestroyRegistered_1) {
      this.__rozieListenersDestroyRegistered_1 = true;
      this.__rozieDestroyRef.onDestroy(() => {
        for (const off of this.__rozieListenersDisposers_1) off();
        this.__rozieListenersDisposers_1 = [];
      });
    }
  });
}

export default Counter;
tsx
import type { JSX } from 'solid-js';
import { createMemo, createSignal, mergeProps, splitProps } from 'solid-js';
import { __rozieInjectStyle, createControllableSignal, mergeListeners, rozieClass } from '@rozie/runtime-solid';

__rozieInjectStyle('Counter-c72e01d0', `.counter[data-rozie-s-c72e01d0] { display: inline-flex; gap: 0.5rem; align-items: center; }
.counter.hovering[data-rozie-s-c72e01d0] { background: rgba(0, 0, 0, 0.04); }
.value[data-rozie-s-c72e01d0] { font-variant-numeric: tabular-nums; min-width: 3ch; text-align: center; }
button[data-rozie-s-c72e01d0] { padding: 0.25rem 0.5rem; }
button[data-rozie-s-c72e01d0]:disabled { opacity: 0.4; cursor: not-allowed; }`);

interface CounterProps {
  value?: number;
  defaultValue?: number;
  onValueChange?: (value: number) => void;
  step?: number;
  min?: number;
  max?: number;
}

export default function Counter(_props: CounterProps): JSX.Element {
  const _merged = mergeProps({ step: 1, min: -Infinity, max: Infinity }, _props);
  const [local, attrs] = splitProps(_merged, ['value', 'step', 'min', 'max']);

  const [value, setValue] = createControllableSignal<number>(_props as unknown as Record<string, unknown>, 'value', 0);
  const [hovering, setHovering] = createSignal(false);
  const canIncrement = createMemo(() => value() + local.step <= local.max);
  const canDecrement = createMemo(() => value() - local.step >= local.min);

  console.log("hello from rozie");
  function increment() {
    if (canIncrement()) setValue(value() + local.step);
  }
  function decrement() {
    if (canDecrement()) setValue(value() - local.step);
  }

  return (
    <>
    <div {...attrs} class={"counter" + " " + rozieClass({ hovering: hovering() }) + (((attrs as unknown as Record<string, unknown>).class as string | undefined) ? " " + ((attrs as unknown as Record<string, unknown>).class as string | undefined) : "")} {...mergeListeners({ onMouseEnter: ($event) => { setHovering(true); }, onMouseLeave: ($event) => { setHovering(false); } }, attrs)} data-rozie-s-c72e01d0="">
      <button aria-label="Decrement" disabled={!canDecrement()} onClick={decrement} data-rozie-s-c72e01d0="">−</button>
      <span class={"value"} data-rozie-s-c72e01d0="">{value()}</span>
      <button aria-label="Increment" disabled={!canIncrement()} onClick={increment} data-rozie-s-c72e01d0="">+</button>
    </div>
    </>
  );
}
ts
import { LitElement, css, html } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { SignalWatcher, signal } from '@lit-labs/preact-signals';
import { createLitControllableProperty, rozieListeners, rozieSpread } from '@rozie/runtime-lit';

@customElement('rozie-counter')
export default class Counter extends SignalWatcher(LitElement) {
  static styles = css`
.counter[data-rozie-s-c72e01d0] { display: inline-flex; gap: 0.5rem; align-items: center; }
.counter.hovering[data-rozie-s-c72e01d0] { background: rgba(0, 0, 0, 0.04); }
.value[data-rozie-s-c72e01d0] { font-variant-numeric: tabular-nums; min-width: 3ch; text-align: center; }
button[data-rozie-s-c72e01d0] { padding: 0.25rem 0.5rem; }
button[data-rozie-s-c72e01d0]:disabled { opacity: 0.4; cursor: not-allowed; }
`;

  @property({ type: Number, attribute: 'value' }) _value_attr: number = 0;
  private _valueControllable = createLitControllableProperty<number>({ host: this, eventName: 'value-change', defaultValue: 0, initialControlledValue: undefined });
  @property({ type: Number, reflect: true }) step: number = 1;
  @property({ type: Number, reflect: true }) min: number = -Infinity;
  @property({ type: Number, reflect: true }) max: number = Infinity;
  private _hovering = signal(false);

  private _disconnectCleanups: Array<() => void> = [];
  // Re-parenting guard: set true once the deferred teardown has actually
  // run (a genuine un-mount), so a subsequent reconnect knows to re-arm.
  private _rozieTornDown = false;

  firstUpdated(): void {
    console.log("hello from rozie");
  }

  disconnectedCallback(): void {
    super.disconnectedCallback();
    queueMicrotask(() => {
      if (this.isConnected || this._rozieTornDown) return;
      this._rozieTornDown = true;
      for (const fn of this._disconnectCleanups) fn();
      this._disconnectCleanups = [];
    });
  }

  attributeChangedCallback(name: string, old: string | null, value: string | null): void {
    super.attributeChangedCallback(name, old, value);
    if (name === 'value') this._valueControllable.notifyAttributeChange(value === null ? 0 : Number(value));
  }

  render() {
    return html`
<div class="${Object.entries({ "counter": true, hovering: this._hovering.value }).filter(([, v]) => v).map(([k]) => k).join(' ')}" ${rozieSpread(this.$attrs)} @mouseenter=${($event: Event) => { this._hovering.value = true; }} @mouseleave=${($event: Event) => { this._hovering.value = false; }} ${rozieListeners(this.$listeners)} data-rozie-s-c72e01d0>
  <button ?disabled=${!this.canDecrement} aria-label="Decrement" @click=${this.decrement} data-rozie-s-c72e01d0>−</button>
  <span class="value" data-rozie-s-c72e01d0>${this.value}</span>
  <button ?disabled=${!this.canIncrement} aria-label="Increment" @click=${this.increment} data-rozie-s-c72e01d0>+</button>
</div>
`;
  }

  get canIncrement() { return this.value + this.step <= this.max; }

  get canDecrement() { return this.value - this.step >= this.min; }

  increment = () => {
  if (this.canIncrement) this._valueControllable.write(prev => prev + this.step);
};

  decrement = () => {
  if (this.canDecrement) this._valueControllable.write(prev => prev - this.step);
};

  get value(): number { return this._valueControllable.read(); }
  set value(v: number) { this._valueControllable.notifyPropertyWrite(v); }

  /**
   * Plan 14-05 — cross-framework attribute fallthrough source. Reads the
   * host custom element's attributes on each call so a consumer-side bound
   * attribute flows through on every render. The `rozieSpread` directive
   * (D-02) does the cross-render diff downstream.
   *
   * Phase 15 follow-up Bug A — declared-prop attribute names are filtered
   * out so `$attrs` returns "rest after declared props" (semantic parity
   * with React/Vue/Svelte/Solid/Angular). Both Lit attribute-naming
   * forms are folded into the skip set: kebab-case for model props
   * (explicit `attribute:`) AND lowercased property name (Lit's default).
   */
  private get $attrs(): Record<string, string> {
    const __skip = new Set<string>(['value', 'step', 'min', 'max']);
    const out: Record<string, string> = {};
    for (const a of Array.from(this.attributes)) {
      if (__skip.has(a.name)) continue;
      out[a.name] = a.value;
    }
    return out;
  }

  /**
   * Phase 15 D-19 — consumer-passed listener cluster placeholder.
   * Lit attaches event listeners directly on the host element via
   * `addEventListener` (no per-instance prop rest binding), so the
   * runtime value is undefined; the `rozieListeners` directive's
   * nullish coercion (`obj ?? {}`) handles the no-op cleanly.
   * The declaration exists to satisfy `tsc --noEmit` on consumer
   * projects with strict mode — bare `$listeners` in `render()`
   * would otherwise raise TS2304 (Cannot find name).
   */
  private get $listeners(): Record<string, EventListener> | undefined {
    return undefined;
  }
}

Pre-v1.0 — internal monorepo.