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) $props.value += $props.step }
const decrement = () => { if (canDecrement) $props.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>

Vue output

vue
<template>

<div :class="['counter', { hovering: hovering }]" @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>

React output

tsx
import { useCallback, useMemo, useState } from 'react';
import { clsx, useControllableState } from '@rozie/runtime-react';
import styles from './Counter.module.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: CounterProps = {
    ..._props,
    step: _props.step ?? 1,
    min: _props.min ?? -Infinity,
    max: _props.max ?? Infinity,
  };
  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 className={clsx(styles.counter, { [styles.hovering]: hovering })} onMouseEnter={(e) => { setHovering(true); }} onMouseLeave={(e) => { setHovering(false); }}>
      <button disabled={!canDecrement} aria-label="Decrement" onClick={decrement}>−</button>
      <span className={styles.value}>{value}</span>
      <button disabled={!canIncrement} aria-label="Increment" onClick={increment}>+</button>
    </div>
    </>
  );
}

Svelte output

svelte
<script lang="ts">
interface Props {
  value?: number;
  step?: number;
  min?: number;
  max?: number;
}

let {
  value = $bindable(0),
  step = 1,
  min = -Infinity,
  max = Infinity,
}: 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 class={["counter", { hovering: hovering }]} onmouseenter={(e) => { hovering = true; }} onmouseleave={(e) => { hovering = false; }}>
  <button disabled={!canDecrement} aria-label="Decrement" onclick={decrement}>−</button>
  <span class="value">{value}</span>
  <button disabled={!canIncrement} aria-label="Increment" onclick={increment}>+</button>
</div>


<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>

Angular output

ts
import { Component, ViewEncapsulation, computed, input, model, signal } from '@angular/core';

@Component({
  selector: 'rozie-counter',
  standalone: true,
  template: `

    <div class="counter" [ngClass]="{ hovering: hovering() }" (mouseenter)="hovering.set(true)" (mouseleave)="hovering.set(false)">
      <button [disabled]="!canDecrement()" aria-label="Decrement" (click)="decrement($event)">−</button>
      <span class="value">{{ value() }}</span>
      <button [disabled]="!canIncrement()" aria-label="Increment" (click)="increment($event)">+</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; }
  `],
})
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());
  };
  decrement = () => {
    if (this.canDecrement()) this.value.set(this.value() - this.step());
  };
}

export default Counter;

Solid output

tsx
import type { JSX } from 'solid-js';
import { createMemo, createSignal, mergeProps, splitProps } from 'solid-js';
import { createControllableSignal } from '@rozie/runtime-solid';

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, rest] = splitProps(_merged, ['value', 'step', 'min', 'max']);

  const [value, setValue] = createControllableSignal(_props 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");
  const increment = () => {
    if (canIncrement()) setValue(value() + local.step);
  };
  const decrement = () => {
    if (canDecrement()) setValue(value() - local.step);
  };

  return (
    <>
    <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>
    <>
    <div class={"counter"} classList={{ hovering: hovering() }} onMouseEnter={(e) => { setHovering(true); }} onMouseLeave={(e) => { setHovering(false); }}>
      <button aria-label="Decrement" disabled={!canDecrement()} onClick={decrement}>−</button>
      <span class={"value"}>{value()}</span>
      <button aria-label="Increment" disabled={!canIncrement()} onClick={increment}>+</button>
    </div>
    </>
    </>
  );
}

Lit output

ts
import { LitElement, css, html } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { SignalWatcher, signal } from '@lit-labs/preact-signals';
import { createLitControllableProperty } from '@rozie/runtime-lit';

@customElement('rozie-counter')
export default class Counter extends SignalWatcher(LitElement) {
  static styles = css`
.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; }
`;

  @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> = [];

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

  disconnectedCallback(): void {
    super.disconnectedCallback();
    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(' ')}" @mouseenter=${(e: Event) => { this._hovering.value = true; }} @mouseleave=${(e: Event) => { this._hovering.value = false; }}>
  <button ?disabled=${!this.canDecrement} aria-label="Decrement" @click=${this.decrement}>−</button>
  <span class="value">${this.value}</span>
  <button ?disabled=${!this.canIncrement} aria-label="Increment" @click=${this.increment}>+</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.value += this.step;
};

  decrement = () => {
  if (this.canDecrement) this.value -= this.step;
};

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

Pre-v1.0 — internal monorepo.