Vue.js 32 🟢 Custom Two-Way Binding using Component v-model
Two-way binding is the pattern where a value flows from the parent to the child as a prop, and the child’s changes flow back to the parent as an event. In Vue, the v-model directive is the syntactic sugar for this pattern. On a native input, v-model="name" expands to :value="name" and @input="name = $event.target.value". On a component, the same directive expands to a prop and an event, and the component’s author decides which prop and which event. The default is modelValue and update:modelValue, but the names can be customized, and the component can support multiple v-model bindings at once.
The underlying mechanism is the same as the fallthrough attributes: the parent passes a prop, and the child emits an event. The v-model directive is the shorthand that wires the two together. When the child emits the update:modelValue event, the parent’s binding is updated with the event’s payload. The child does not mutate the prop — it cannot, because the prop is readonly — and the parent does not reach into the child. The event is the contract, and the prop is the state.
The customization has three parts. The default v-model uses the modelValue prop and the update:modelValue event. The named v-model:title uses the title prop and the update:title event. The argument in the directive is the name, and the component declares the prop and emits the event with the update: prefix. A component can have multiple named bindings, each with its own prop and event, and the parent uses them independently.
This chapter covers three areas. First, why component v-model exists — the prop-and-event pattern and the syntactic sugar. Second, how to implement the default and the named v-model — the defineProps declaration, the defineEmits declaration, and the update: event naming. Third, the advanced patterns — the multiple bindings, the modifiers, the writable computed, and the custom v-model argument. The chapter ends with a complete example session, a quick reference, best practices, common pitfalls, real-world examples, and diagrams showing the binding flow.
Key point: v-model on a component is the shorthand for a prop and an update: event. The default uses the modelValue prop and the update:modelValue event. The named v-model:title uses the title prop and the update:title event. The component declares the prop with defineProps and emits the event with defineEmits. The prop is readonly, and the event is the only way to change the parent’s state.
Why component v-model exists
The prop-and-event problem. The communication between a parent and a child is one-way: the prop flows down, and the event flows up. A two-way binding is a prop and an event that are paired. The parent passes the value as a prop, and the child emits the new value as an event. The parent listens to the event and updates the value. The pattern is repeated for every two-way binding, and the boilerplate is the same: the prop, the listener, and the update.
The syntactic-sugar problem. The v-model directive is the shorthand for the pattern. On a native input, the compiler expands the directive into the prop and the event. On a component, the compiler does the same, and the component’s author chooses the names. The expansion is the contract: v-model="value" becomes :modelValue="value" and @update:modelValue="value = $event". The component declares the modelValue prop and emits the update:modelValue event.
The naming problem. The default names are modelValue and update:modelValue. The names are conventional, and they are what the compiler expects when the directive has no argument. The named binding v-model:title changes the names to title and update:title. The component declares the title prop and emits the update:title event. The names are the contract, and the component’s author chooses them.
The multiple-binding problem. A component that needs more than one two-way binding uses the named v-model for each. A UserForm component with the firstName and the lastName uses v-model:firstName and v-model:lastName. Each binding has its own prop and event, and the parent uses them independently. The alternative — a single object prop with the multiple fields — is less ergonomic and less type-safe.
The modifier problem. A v-model can have a modifier: v-model.trim, v-model.number, v-model.lazy. The modifier is passed to the component, and the component decides how to handle it. The component reads the modelModifiers prop (or the argModifiers for the named bindings) and applies the modifier’s behavior. The pattern is the way the component library exposes the modifiers like the native inputs.
The trade-off. The v-model is convenient, but it hides the prop-and-event pair. A developer who does not know the expansion can be confused about where the value comes from and how the update happens. The named bindings add the verbosity to the template, and the multiple bindings can make the component’s interface harder to read. The trade-off is between the convenience of the shorthand and the explicitness of the prop-and-event pair.
a. The default v-model
The default v-model on a component uses the modelValue prop and the update:modelValue event. The component declares the prop and emits the event.
<!-- CustomInput.vue -->
<script setup lang="ts">
defineProps<{
modelValue: string;
}>();
const emit = defineEmits<{
'update:modelValue': [value: string];
}>();
function handleInput(event: Event) {
const target = event.target as HTMLInputElement;
emit('update:modelValue', target.value);
}
</script>
<template>
<input :value="modelValue" @input="handleInput" />
</template>
The parent uses the component with the v-model:
<script setup>
import { ref } from 'vue';
import CustomInput from './CustomInput.vue';
const name = ref('');
</script>
<template>
<CustomInput v-model="name" />
<p>Name: {{ name }}</p>
</template>
The v-model="name" expands to :modelValue="name" and @update:modelValue="name = $event". The modelValue prop passes the value down, and the update:modelValue event passes the new value up. The name ref is updated when the input changes.
The declaration. The defineProps declares the modelValue prop, and the defineEmits declares the update:modelValue event. The event’s payload is the new value. The handleInput function reads the input’s value and emits the event.
The prop is readonly. The component cannot assign to the modelValue prop. The only way to change the value is to emit the update:modelValue event, and the parent updates its own state.
The template binding. The :value="modelValue" binds the prop to the input’s value, and the @input="handleInput" listens to the input’s change. The handleInput emits the update:modelValue event with the new value.
b. The named v-model
The named v-model:title uses the title prop and the update:title event. The component declares the prop and emits the event with the update: prefix.
<!-- CustomInput.vue with the named binding -->
<script setup lang="ts">
defineProps<{
title: string;
content: string;
}>();
const emit = defineEmits<{
'update:title': [value: string];
'update:content': [value: string];
}>();
</script>
<template>
<input
:value="title"
@input="emit('update:title', ($event.target as HTMLInputElement).value)"
/>
<textarea
:value="content"
@input="emit('update:content', ($event.target as HTMLTextAreaElement).value)"
/>
</template>
The parent uses the component with the two named bindings:
<script setup>
import { ref } from 'vue';
import CustomInput from './CustomInput.vue';
const title = ref('');
const content = ref('');
</script>
<template>
<CustomInput v-model:title="title" v-model:content="content" />
<p>Title: {{ title }}</p>
<p>Content: {{ content }}</p>
</template>
The v-model:title="title" expands to :title="title" and @update:title="title = $event". The v-model:content="content" expands to :content="content" and @update:content="content = $event". The two bindings are independent, and each has its own prop and event.
The naming rule. The argument in the v-model:name is the prop name, and the event name is update:name. The component declares the prop with the argument name and emits the event with the update: prefix.
The multiple bindings. A component can have any number of the named bindings. Each one is a prop-and-event pair, and the parent uses them independently. The component’s interface is explicit: the props are the bindings, and the events are the updates.
The combination with the default. A component can have the default v-model and the named bindings at the same time. The default uses the modelValue and the update:modelValue, and the named bindings use their own names.
c. The advanced patterns
The writable computed. A component that needs to transform the value before emitting uses a writable computed. The computed reads from the prop and writes to the emit.
<script setup lang="ts">
import { computed } from 'vue';
const props = defineProps<{
modelValue: string;
}>();
const emit = defineEmits<{
'update:modelValue': [value: string];
}>();
const value = computed({
get: () => props.modelValue,
set: (newValue) => emit('update:modelValue', newValue),
});
</script>
<template>
<input v-model="value" />
</template>
The value computed reads from the modelValue prop and emits the update:modelValue event when it is set. The v-model="value" on the input binds the computed, and the input’s change flows through the computed to the parent. The pattern is useful when the component needs to transform the value, such as the trimming or the parsing.
The modifiers. A v-model can have the modifiers, and the component receives them in the modelModifiers prop (or the argModifiers for the named bindings).
<!-- CustomInput.vue with the modifiers -->
<script setup lang="ts">
const props = defineProps<{
modelValue: string;
modelModifiers?: {
capitalize?: boolean;
};
}>();
const emit = defineEmits<{
'update:modelValue': [value: string];
}>();
function handleInput(event: Event) {
const target = event.target as HTMLInputElement;
let value = target.value;
if (props.modelModifiers?.capitalize) {
value = value.charAt(0).toUpperCase() + value.slice(1);
}
emit('update:modelValue', value);
}
</script>
<template>
<input :value="modelValue" @input="handleInput" />
</template>
The parent uses the modifier:
<CustomInput v-model.capitalize="name" />
The modelModifiers prop contains the modifiers, and the component applies them. The named binding uses the argModifiers prop: v-model:title.capitalize populates the titleModifiers prop.
The defineModel macro. Vue 3.4 introduced the defineModel macro, which simplifies the v-model implementation. The macro declares a ref that is automatically wired to the prop and the event.
<script setup lang="ts">
const model = defineModel<string>();
function handleInput(event: Event) {
model.value = (event.target as HTMLInputElement).value;
}
</script>
<template>
<input :value="model" @input="handleInput" />
</template>
The defineModel<string>() returns a ref that reads from the modelValue prop and emits the update:modelValue event when it is assigned. The defineModel macro removes the boilerplate of the defineProps and the defineEmits for the v-model. The named binding is declared with the argument: const title = defineModel<string>('title') .
The defineModel with the default and the required. The defineModel macro accepts the options: the default and the required. The macro is the modern way to implement the v-model, and the manual defineProps and defineEmits are the fallback for the cases the macro does not cover.
const model = defineModel<string>({ default: '' });
const model = defineModel<string>({ required: true });
Complete Example Session
<!-- ============================================
PART 1: THE DEFAULT v-model
============================================ -->
<!-- CustomInput.vue -->
<script setup lang="ts">
defineProps<{
modelValue: string;
}>();
const emit = defineEmits<{
'update:modelValue': [value: string];
}>();
function handleInput(event: Event) {
const target = event.target as HTMLInputElement;
emit('update:modelValue', target.value);
}
</script>
<template>
<input :value="modelValue" @input="handleInput" />
</template>
<!-- ============================================
PART 2: THE PARENT USING THE DEFAULT v-model
============================================ -->
<script setup>
import { ref } from 'vue';
import CustomInput from './CustomInput.vue';
const name = ref('');
</script>
<template>
<CustomInput v-model="name" />
<p>Name: {{ name }}</p>
</template>
<!-- v-model="name" expands to:
:modelValue="name"
@update:modelValue="name = $event" -->
<!-- ============================================
PART 3: THE NAMED v-model
============================================ -->
<!-- UserForm.vue -->
<script setup lang="ts">
defineProps<{
firstName: string;
lastName: string;
}>();
const emit = defineEmits<{
'update:firstName': [value: string];
'update:lastName': [value: string];
}>();
</script>
<template>
<input
:value="firstName"
@input="emit('update:firstName', ($event.target as HTMLInputElement).value)"
/>
<input
:value="lastName"
@input="emit('update:lastName', ($event.target as HTMLInputElement).value)"
/>
</template>
<!-- ============================================
PART 4: THE PARENT USING THE NAMED v-model
============================================ -->
<script setup>
import { ref } from 'vue';
import UserForm from './UserForm.vue';
const first = ref('');
const last = ref('');
</script>
<template>
<UserForm v-model:firstName="first" v-model:lastName="last" />
<p>{{ first }} {{ last }}</p>
</template>
<!-- ============================================
PART 5: THE WRITABLE COMPUTED
============================================ -->
<script setup lang="ts">
import { computed } from 'vue';
const props = defineProps<{
modelValue: string;
}>();
const emit = defineEmits<{
'update:modelValue': [value: string];
}>();
const value = computed({
get: () => props.modelValue,
set: (newValue) => emit('update:modelValue', newValue.trim()),
});
</script>
<template>
<input v-model="value" />
</template>
<!-- The value is trimmed before it is emitted. -->
<!-- ============================================
PART 6: THE MODIFIERS
============================================ -->
<script setup lang="ts">
const props = defineProps<{
modelValue: string;
modelModifiers?: {
capitalize?: boolean;
};
}>();
const emit = defineEmits<{
'update:modelValue': [value: string];
}>();
function handleInput(event: Event) {
const target = event.target as HTMLInputElement;
let value = target.value;
if (props.modelModifiers?.capitalize) {
value = value.charAt(0).toUpperCase() + value.slice(1);
}
emit('update:modelValue', value);
}
</script>
<template>
<input :value="modelValue" @input="handleInput" />
</template>
<!-- The parent: <CustomInput v-model.capitalize="name" /> -->
<!-- ============================================
PART 7: THE defineModel MACRO
============================================ -->
<script setup lang="ts">
const model = defineModel<string>();
function handleInput(event: Event) {
model.value = (event.target as HTMLInputElement).value;
}
</script>
<template>
<input :value="model" @input="handleInput" />
</template>
<!-- The defineModel macro removes the boilerplate. -->
<!-- ============================================
PART 8: THE defineModel WITH THE OPTIONS
============================================ -->
<script setup lang="ts">
const model = defineModel<string>({ default: '' });
const title = defineModel<string>('title', { required: true });
</script>
<!-- ============================================
PART 9: THE MULTIPLE defineModel
============================================ -->
<script setup lang="ts">
const firstName = defineModel<string>('firstName');
const lastName = defineModel<string>('lastName');
</script>
<template>
<input v-model="firstName" />
<input v-model="lastName" />
</template>
<!-- The parent:
<UserForm v-model:firstName="first" v-model:lastName="last" /> -->
<!-- ============================================
PART 10: THE COMPLETE COMPONENT
============================================ -->
<!-- SearchBox.vue -->
<script setup lang="ts">
import { computed } from 'vue';
const model = defineModel<string>({ default: '' });
const props = defineProps<{
placeholder?: string;
modelModifiers?: { trim?: boolean };
}>();
const emit = defineEmits<{
search: [query: string];
}>();
function handleInput(event: Event) {
const target = event.target as HTMLInputElement;
let value = target.value;
if (props.modelModifiers?.trim) {
value = value.trim();
}
model.value = value;
}
function handleSearch() {
emit('search', model.value);
}
</script>
<template>
<div class="search-box">
<input
:value="model"
:placeholder="placeholder"
@input="handleInput"
@keyup.enter="handleSearch"
/>
<button @click="handleSearch">Search</button>
</div>
</template>
<!-- The parent:
<SearchBox v-model.trim="query" @search="onSearch" /> -->
The ten parts show the default v-model, the parent using it, the named v-model, the parent using the named bindings, the writable computed, the modifiers, the defineModel macro, the defineModel with the options, the multiple defineModel, and the complete component.
Quick Reference
The Default v-model
| Template | Expands to |
|---|---|
v-model="value" | :modelValue="value" + @update:modelValue="value = $event" |
| The prop | modelValue |
| The event | update:modelValue |
| The modifier prop | modelModifiers |
The Named v-model
| Template | Expands to |
|---|---|
v-model:title="title" | :title="title" + @update:title="title = $event" |
| The prop | title |
| The event | update:title |
| The modifier prop | titleModifiers |
The Implementation
| Approach | Declaration |
|---|---|
| Manual | defineProps + defineEmits + emit |
defineModel | const model = defineModel<T>() |
The named defineModel | const title = defineModel<T>('title') |
| The options | defineModel<T>({ default, required }) |
The Modifiers
| Modifier | Prop | Behavior |
|---|---|---|
.trim | modelModifiers.trim | Trim the value |
.number | modelModifiers.number | Parse the number |
.capitalize | modelModifiers.capitalize | Custom |
| The named | argModifiers | The modifier for the named binding |
The Writable Computed
| Part | Code |
|---|---|
| The get | get: () => props.modelValue |
| The set | set: (v) => emit('update:modelValue', v) |
| The template | v-model="value" |
Best Practices
✅ Do This:
<script setup>
// Use defineModel for the simple cases
const model = defineModel<string>(); // ✅
// Use the named defineModel for the multiple bindings
const firstName = defineModel<string>('firstName'); // ✅
// Use the manual defineProps/defineEmits for the complex cases
defineProps<{ modelValue: string }>();
defineEmits<{ 'update:modelValue': [value: string] }>(); // ✅
// Use the writable computed for the transformations
const value = computed({ get, set }); // ✅
// Declare the modifier prop
defineProps<{ modelModifiers?: { trim?: boolean } }>(); // ✅
</script>
❌ Don’t Do This:
<script setup>
// Don't mutate the modelValue prop directly
modelValue.value = 'new'; // the prop is readonly // ❌
// Don't forget to emit the update event
function handleInput(e) { /* no emit */ } // ❌
// Don't use the wrong event name
emit('update:value', x); // should be 'update:modelValue' // ❌
// Don't forget the named modifier prop
// v-model:title.trim needs the titleModifiers prop // ❌
// Don't mix the manual and the defineModel for the same binding
const model = defineModel(); defineProps<{ modelValue: string }>(); // ❌
</script>
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| The v-model does not work | The wrong prop or event name | Use modelValue and update:modelValue |
| The prop is readonly | Mutating the modelValue | Emit the event |
| The named binding does not work | The wrong argument | Use the v-model:name and the update:name |
| The modifier is ignored | No modelModifiers prop | Declare the prop |
The defineModel is not available | Vue < 3.4 | Use the manual defineProps/defineEmits |
| The value is not updated | No emit call | Emit the update:modelValue |
| The multiple bindings conflict | The same prop name | Use the distinct names |
Real-World Examples
1. The Default v-model
<CustomInput v-model="name" />
2. The Named v-model
<UserForm v-model:firstName="first" v-model:lastName="last" />
3. The defineModel
const model = defineModel<string>();
4. The Named defineModel
const title = defineModel<string>('title');
5. The Manual Implementation
defineProps<{ modelValue: string }>();
defineEmits<{ 'update:modelValue': [value: string] }>();
6. The Writable Computed
const value = computed({ get: () => props.modelValue, set: (v) => emit('update:modelValue', v) });
7. The Modifier
defineProps<{ modelModifiers?: { trim?: boolean } }>();
8. The defineModel with the Options
const model = defineModel<string>({ default: '' });
9. The Multiple defineModel
const firstName = defineModel<string>('firstName');
10. The Complete Search Box
<SearchBox v-model.trim="query" @search="onSearch" />
Visual
The v-model Expansion
┌──────────────────────────────────────────────────────────────┐
│ THE v-model EXPANSION │
│ │
│ Parent: │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ <CustomInput v-model="name" /> │ │
│ └──────────────────────────────────────────────────────┘ │
│ │ │
│ │ The compiler expands the directive. │
│ ▼ │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ <CustomInput │ │
│ │ :modelValue="name" │ │
│ │ @update:modelValue="name = $event" │ │
│ │ /> │ │
│ └──────────────────────────────────────────────────────┘ │
│ │ │
│ │ The prop flows down. │
│ │ The event flows up. │
│ ▼ │
│ Child: │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ defineProps<{ modelValue: string }>() │ │
│ │ defineEmits<{ 'update:modelValue': [value: string] }>()
│ │ │ │
│ │ <input :value="modelValue" @input="handleInput" /> │ │
│ └──────────────────────────────────────────────────────┘ │
│ │
└──────────────────────────────────────────────────────────────┘
The Named v-model
┌──────────────────────────────────────────────────────────────┐
│ THE NAMED v-model │
│ │
│ Parent: │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ <UserForm │ │
│ │ v-model:firstName="first" │ │
│ │ v-model:lastName="last" │ │
│ │ /> │ │
│ └──────────────────────────────────────────────────────┘ │
│ │ │
│ │ Expands to: │
│ ▼ │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ <UserForm │ │
│ │ :firstName="first" │ │
│ │ @update:firstName="first = $event" │ │
│ │ :lastName="last" │ │
│ │ @update:lastName="last = $event" │ │
│ │ /> │ │
│ └──────────────────────────────────────────────────────┘ │
│ │
│ Child: │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ defineProps<{ │ │
│ │ firstName: string; │ │
│ │ lastName: string; │ │
│ │ }>() │ │
│ │ defineEmits<{ │ │
│ │ 'update:firstName': [value: string]; │ │
│ │ 'update:lastName': [value: string]; │ │
│ │ }>() │ │
│ └──────────────────────────────────────────────────────┘ │
│ │
└──────────────────────────────────────────────────────────────┘
The defineModel Macro
┌──────────────────────────────────────────────────────────────┐
│ THE defineModel MACRO │
│ │
│ WITHOUT defineModel: │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ const props = defineProps<{ modelValue: string }>();│ │
│ │ const emit = defineEmits<{ │ │
│ │ 'update:modelValue': [value: string]; │ │
│ │ }>(); │ │
│ │ │ │
│ │ function handleInput(e) { │ │
│ │ emit('update:modelValue', e.target.value); │ │
│ │ } │ │
│ └──────────────────────────────────────────────────────┘ │
│ │
│ WITH defineModel: │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ const model = defineModel<string>(); │ │
│ │ │ │
│ │ function handleInput(e) { │ │
│ │ model.value = e.target.value; │ │
│ │ } │ │
│ │ │ │
│ │ The macro wires the prop and the event. │ │
│ └──────────────────────────────────────────────────────┘ │
│ │
└──────────────────────────────────────────────────────────────┘
The Writable Computed
┌──────────────────────────────────────────────────────────────┐
│ THE WRITABLE COMPUTED │
│ │
│ const props = defineProps<{ modelValue: string }>(); │
│ const emit = defineEmits<{ │
│ 'update:modelValue': [value: string]; │
│ }>(); │
│ │
│ const value = computed({ │
│ get: () => props.modelValue, │
│ set: (newValue) => emit('update:modelValue', newValue.trim()),
│ }); │
│ │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ <input v-model="value" /> │ │
│ │ │ │
│ │ The input's change: │ │
│ │ → value.value = newValue │ │
│ │ → the setter runs │ │
│ │ → emit('update:modelValue', trimmed) │ │
│ │ → the parent updates its state │ │
│ │ → the prop updates │ │
│ │ → the getter returns the new value │ │
│ └──────────────────────────────────────────────────────┘ │
│ │
│ The transformation (trim) happens in the setter. │
│ │
└──────────────────────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
v-model on a component | The prop + the update: event |
| The default prop | modelValue |
| The default event | update:modelValue |
| The named binding | v-model:title → title + update:title |
| The modifier prop | modelModifiers (the default) |
| The named modifier prop | argModifiers |
The defineModel macro | Vue 3.4+; the ref-based declaration |
| The writable computed | The get/set for the transformations |
| The prop readonly | The child cannot mutate the prop |
Key takeaways:
v-modelon a component is the shorthand for a prop and anupdate:event. The default uses themodelValueprop and theupdate:modelValueevent. The compiler expands the directive into the prop binding and the event listener .- The component declares the prop with
definePropsand emits the event withdefineEmits. The prop is readonly, and the event is the only way to change the parent’s state. The event’s payload is the new value . - The named
v-model:titleuses thetitleprop and theupdate:titleevent. The argument in the directive is the prop name, and the event name is theupdate:prefix plus the argument. The component can have the multiple named bindings . - The
defineModelmacro simplifies the implementation. The macro declares a ref that is automatically wired to the prop and the event. ThedefineModel<string>()is the modern replacement for the manualdefinePropsanddefineEmits. - The modifiers are passed to the component. The
v-model.trimpopulates themodelModifiersprop, and thev-model:title.trimpopulates thetitleModifiersprop. The component reads the modifiers and applies the behavior . - The writable computed is the pattern for the transformations. The
getreturns the prop, and thesetemits the event. The transformation (the trim, the parse) happens in the setter . - The prop is readonly. The component cannot assign to the
modelValueprop directly. The only way to change the value is to emit theupdate:modelValueevent, and the parent updates its own state . - The
defineModelmacro is available in Vue 3.4 and later. The projects on the earlier versions use the manualdefinePropsanddefineEmits. The macro removes the boilerplate and makes the implementation more concise .
Remember: The v-model on a component is the syntactic sugar for the prop-and-event pair. The default uses the modelValue prop and the update:modelValue event, and the named bindings use the v-model:name syntax with the name prop and the update:name event. The component declares the prop with defineProps and emits the event with defineEmits, or it uses the defineModel macro. The modifiers are passed in the modelModifiers or the argModifiers prop, and the writable computed is the pattern for the transformations. The result is a component that supports the two-way binding with the explicit prop-and-event contract, the multiple bindings, and the modifiers.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!