Appearance
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); }
}