| |

Angular 66 🅰️ Animations — Basics

Angular’s animation system gives you programmatic control over how elements transition between states. Unlike pure CSS animations, the @angular/animations package lets you define animations directly in component metadata, coordinate parent-child animations, trigger changes based on data state, and hook into lifecycle callbacks when motion starts and finishes. The package provides a complete state-machine model: you declare named states with styles, connect them with transitions that specify timing, and bind triggers to template expressions. Angular handles the rest — scheduling, interpolating values, and cleaning up.

This chapter covers the foundational building blocks: trigger, state, style, transition, animate, and keyframes, along with the critical enter/leave lifecycle and the patterns you’ll use in real applications. One important context before we begin: the Angular team has deprecated the @angular/animations package as of v20.2, with removal planned for v23. The recommendation is to use native CSS for all new code. However, understanding the animations package remains essential for maintaining existing applications, and the architectural patterns — state machines, transitions, parent-child coordination — translate directly to how you think about motion in any Angular app. If you’re starting fresh, consider native CSS. If you’re working with existing code or need the programmatic control the package offers, this chapter is for you.

Key point: Angular animations run in a specific order. The :enter transition executes after ngOnInit and the first change detection cycle. The :leave transition executes after ngOnDestroy. When animations are triggered, parent animations always take priority and child animations are blocked unless explicitly queried with animateChild(). Plain state changes without matching transitions occur instantly with no animation — transitions are what define the journey between states .


Why Angular animations exist

CSS animations alone can handle simple cases: hover effects, basic fades, rotating loaders. But they fall short in several scenarios that Angular applications encounter constantly.

The state problem. CSS animations are difficult to trigger from application state. You can toggle a class, but you cannot easily say “when this component’s isExpanded property becomes true, animate the height from 0 to the content’s natural height.” Angular animations let you bind animations directly to component properties — [@expandCollapse]="isExpanded ? 'expanded' : 'collapsed'" — and the animation runs automatically when the bound value changes .

The lifecycle problem. CSS cannot animate an element into or out of the DOM. You can fade an element’s opacity, but the element remains in the document flow, consuming space. Angular’s :enter and :leave aliases handle the full lifecycle: the element is added to the DOM, the enter animation runs, and on removal, the leave animation runs before the element is detached .

The coordination problem. When a parent and child both have animations, coordinating them in CSS requires manual timing calculations and fragile coupling. Angular’s query() and animateChild() functions let a parent animation explicitly control child animations, running them in parallel, sequence, or with staggered delays .

The callback problem. Angular animations emit events when they start and finish: (@triggerName.start) and (@triggerName.done). This lets you chain logic — run a follow-up action when an animation completes, disable a button during motion, or log performance data .

The trade-off. The animations package adds bundle size (the package itself, plus @angular/animations/browser and the animation engine) and complexity. For simple fades, CSS is lighter. For state-driven, coordinated, callback-aware motion in an Angular application, the package remains the most integrated solution — which is why it dominated Angular codebases for years and why understanding it is still necessary.


a. The Core Primitives: trigger, state, style

Every Angular animation starts with a trigger. You define triggers in the animations array of your @Component decorator. A trigger has a name (used in templates) and contains one or more states. A state defines a named set of CSS styles that apply when the element is in that state. The style function wraps CSS properties into an object Angular understands .

import { trigger, state, style } from '@angular/animations';

@Component({
  selector: 'app-toggle-box',
  template: `<div [@openClose]="isOpen ? 'open' : 'closed'">Content</div>`,
  animations: [
    trigger('openClose', [
      state('open', style({ height: '200px', opacity: 1, backgroundColor: 'yellow' })),
      state('closed', style({ height: '100px', opacity: 0.8, backgroundColor: 'blue' })),
    ]),
  ],
})
export class ToggleBoxComponent {
  isOpen = false;
}

The template binds the trigger to a state expression: [@openClose]="isOpen ? 'open' : 'closed'". When isOpen becomes true, the element enters the open state and applies those styles. When false, it enters closed. Without a transition, however, the change is instantaneous — no animation occurs. States define the endpoints; transitions define the journey .

There’s a crucial distinction here: state() styles persist after the animation completes, while transition() styles only apply during motion . This matters when you need an element to remain styled after a change finishes. If you define height: '200px' in a state, the element stays 200px tall after animating. If you define it only in a transition, the style vanishes when the animation ends. When animations are disabled, transition styles can be skipped, but state styles cannot .

The style() function accepts both camelCase (backgroundColor) and dashed-case ('background-color') properties . Dashed-case keys must be quoted strings, since JavaScript would otherwise interpret the hyphens as arithmetic operators. CamelCase keys work without quotes. Both conventions produce identical results; pick one and stay consistent.

Angular also provides reserved state names worth knowing. The void state represents an element detached from the application — not yet rendered or already removed. The * (asterisk) indicates the default state, used as a fallback when the active state isn’t declared within the trigger . When you bind a trigger to a value that has no corresponding state() declaration, Angular treats it as the default state, and no state-specific styles apply.

The binding syntax in the template is worth examining closely. [@triggerName]="expression" — the expression can be a string (naming a state), a boolean, a number, or any value. Angular matches the value against declared states. If no match is found, Angular falls back to the * default state if one exists, or applies no state styles. The @ prefix on the attribute name is what distinguishes an animation binding from a regular property binding.


b. Transitions and Timing: Making It Move

A transition connects two states and specifies how the element moves between them. The transition() function takes two arguments: a state-change expression and one or more animation steps. The animate function inside the transition defines duration, delay, and easing .

import { trigger, state, style, transition, animate } from '@angular/animations';

animations: [
  trigger('openClose', [
    state('open', style({ height: '200px', opacity: 1, backgroundColor: 'yellow' })),
    state('closed', style({ height: '100px', opacity: 0.8, backgroundColor: 'blue' })),
    transition('open => closed', [animate('1s')]),
    transition('closed => open', [animate('0.5s')]),
  ]),
],

The => operator means unidirectional (open to closed only). Use <=> for bidirectional transitions that share the same timing . The animate() function accepts a timing string in the format 'duration delay easing' — for example, '0.2s 100ms ease-out' waits 100ms, runs for 200ms, and decelerates at the end . When no styles are passed to animate(), it uses the destination state’s styles as the target. The final step in a transition that uses animate() with no style data is automatically considered the final animation arc, and Angular handles the CSS to ensure the element reaches the correct final state .

You can also use wildcard states to reduce repetition. A transition like '* => closed' applies whenever any state changes to closed, regardless of the starting state . The * => * pattern catches any-to-any changes and serves as a fallback, but remember: transitions are matched in the order they’re defined, so place specific transitions before wildcards. If you define 'open => closed' after '* => *', the wildcard catches the change first, and your specific transition never runs. This ordering rule is one of the most common sources of “why isn’t my animation working?” confusion.

Timing strings support three formats. A plain number is milliseconds: animate(500). A string can specify duration alone ('500ms' or '0.5s'), duration with delay ('0.2s 100ms'), or all three ('0.2s 100ms ease-out'). The easing values mirror CSS: ease, ease-in, ease-out, ease-in-out, or a cubic-bezier() call . Easing controls how the animation accelerates and decelerates. ease-out starts fast and slows down, which feels natural for elements entering the view. ease-in starts slow and speeds up, which suits elements leaving. ease-in-out starts slow, accelerates in the middle, and decelerates at the end — the standard curve for most UI transitions.

Boolean values in transitions have a special behavior. If a trigger contains a Boolean value as a binding value, the value can be matched using a transition expression that compares true and false, or 1 and 0 . This allows you to define states without naming them, using the boolean itself as the state value.

For numeric values, Angular provides :increment and :decrement aliases. A transition from a lower number to a higher one can use :increment, and a transition from a higher number to a lower one can use :decrement . These are useful for counters, progress indicators, and pagination controls where the direction of change matters.

You can also define transition styles inline. A transition can contain a style() call before animate() to set starting styles, after animate() to set ending styles, or both. Angular interpolates between the start and end styles over the duration of the animation. If a property appears in both the starting style and the destination state’s style, Angular uses the starting style for the beginning of the animation and the state style for the end.


c. Enter and Leave: Animating Elements into Existence

Angular provides special transition aliases for elements entering and leaving the DOM: :enter (equivalent to void => *) and :leave (* => void). The void state represents an element that isn’t part of the DOM — either not yet rendered or already removed . These aliases are essential for list animations, conditional rendering, and route transitions.

trigger('fadeInOut', [
  transition(':enter', [
    style({ opacity: 0, transform: 'translateY(-20px)' }),
    animate('300ms ease-out', style({ opacity: 1, transform: 'translateY(0)' })),
  ]),
  transition(':leave', [
    animate('300ms ease-in', style({ opacity: 0, transform: 'translateX(100%)' })),
  ]),
]),

Here, entering elements start transparent and shifted up, then fade in and slide down. Leaving elements slide right and fade out. The style() before animate() in the :enter block sets the starting styles — without it, the element would appear instantly at its final position before animating, causing a visual jump.

One subtle but critical behavior: when an element with a trigger enters the DOM, its :enter transition always executes. But its :leave transition will not execute if the element is removed alongside its parent . The element gets removed “without warning” before the transition has a chance to run. The only way :leave fires is if the element is exiting the DOM on its own. If you’re animating a list item’s removal, the item must be removed independently — not as part of a parent container being destroyed.

Angular’s animations execute in a specific order. The :enter state change gets called after ngOnInit and the first change detection cycle, whereas :leave happens right after the element’s ngOnDestroy is called . Each time an animation is triggered, the parent animation gets priority and child animations are blocked unless explicitly queried with animateChild() .

There’s also a special binding for disabling animations: @.disabled. When placed on an element with a truthy value, it prevents all animations on that element and any nested elements . This is useful during end-to-end testing to eliminate timing jitter. You can place it on the root component to disable animations application-wide . The binding does not stop animation callbacks from firing, however, so if you use @.disabled with animation callbacks, ensure those callbacks do not trigger further state changes that could create loops.

The animation callbacks are worth understanding in detail. Angular emits start and done events for each trigger. Bind them with (@triggerName.start)="onStart($event)" and (@triggerName.done)="onDone($event)". The event object includes the trigger name, phase name (start or done), total time, from and to states, and the element itself . These callbacks fire for every animation phase on the trigger, so a transition with two steps emits two start events and two done events. Use them for cleanup, chaining, or logging.


Complete Example Session

This session builds a working animation from scratch, demonstrating the full workflow.

// ============================================
// PART 1: THE BASIC TRIGGER
// ============================================

import { trigger, state, style, transition, animate } from '@angular/animations';

@Component({
  selector: 'app-panel',
  template: `
    <button (click)="toggle()">Toggle</button>
    <div [@expandCollapse]="isExpanded ? 'expanded' : 'collapsed'">
      <ng-content></ng-content>
    </div>
  `,
  animations: [
    trigger('expandCollapse', [
      state('expanded', style({ height: '*', opacity: 1 })),
      state('collapsed', style({ height: '0px', opacity: 0, overflow: 'hidden' })),
      transition('expanded <=> collapsed', [animate('400ms cubic-bezier(0.4, 0.0, 0.2, 1)')]),
    ]),
  ],
})
export class PanelComponent {
  isExpanded = true;
  toggle() { this.isExpanded = !this.isExpanded; }
}

// ============================================
// PART 2: THE ENTER/LEAVE PAIR
// ============================================

@Component({
  selector: 'app-toast',
  template: `<div [@fadeInOut]="'in'">Toast message</div>`,
  animations: [
    trigger('fadeInOut', [
      transition(':enter', [
        style({ opacity: 0, transform: 'translateY(-20px)' }),
        animate('300ms ease-out', style({ opacity: 1, transform: 'translateY(0)' })),
      ]),
      transition(':leave', [
        animate('300ms ease-in', style({ opacity: 0, transform: 'translateX(100%)' })),
      ]),
    ]),
  ],
})
export class ToastComponent {}

// ============================================
// PART 3: THE ANIMATION CALLBACK
// ============================================

@Component({
  selector: 'app-callback-demo',
  template: `
    <div [@fadeInOut]="'in'"
         (@fadeInOut.start)="onStart($event)"
         (@fadeInOut.done)="onDone($event)">
      Content
    </div>
  `,
  animations: [ /* ... */ ],
})
export class CallbackDemoComponent {
  onStart(event: AnimationEvent) { console.log('started', event.fromState, event.toState); }
  onDone(event: AnimationEvent) { console.log('done', event.totalTime); }
}

// ============================================
// PART 4: THE KEYFRAMES ANIMATION
// ============================================

import { keyframes } from '@angular/animations';

trigger('pulse', [
  transition('* => *', [
    animate('1s', keyframes([
      style({ opacity: 1, offset: 0 }),
      style({ opacity: 0.5, offset: 0.5 }),
      style({ opacity: 1, offset: 1 }),
    ])),
  ]),
])

// ============================================
// PART 5: THE DISABLED BINDING
// ============================================

@Component({
  selector: 'app-test-host',
  template: `
    <div [@.disabled]="isDisabled">
      <div [@fadeInOut]="'in'">Content</div>
    </div>
  `,
})
export class TestHostComponent {
  isDisabled = true;  // animations skipped entirely
}

// ============================================
// PART 6: THE VOID STATE
// ============================================

// void => * is equivalent to :enter
// * => void is equivalent to :leave

transition('void => *', [ /* same as :enter */ ])
transition('* => void', [ /* same as :leave */ ])

// ============================================
// PART 7: THE WILDCARD ORDER TRAP
// ============================================

animations: [
  trigger('orderDemo', [
    // ❌ wildcard first — specific transition never runs
    transition('* => *', [animate('1s')]),
    transition('open => closed', [animate('0.2s')]),  // never matched

    // ✅ specific first — wildcard acts as fallback
    transition('open => closed', [animate('0.2s')]),
    transition('* => *', [animate('1s')]),
  ]),
]

// ============================================
// PART 8: THE INCREMENT/DECREMENT ALIASES
// ============================================

trigger('counter', [
  transition(':increment', [animate('200ms', style({ color: 'green' }))]),
  transition(':decrement', [animate('200ms', style({ color: 'red' }))]),
])

// ============================================
// PART 9: THE HEIGHT AUTO TRAP
// ============================================

// ❌ cannot animate to/from auto
state('expanded', style({ height: 'auto' }))

// ✅ wildcard height resolves to actual content height
state('expanded', style({ height: '*' }))

// ============================================
// PART 10: THE SSR CAVEAT
// ============================================

// Animations do not execute on the server.
// :enter and :leave transitions are no-ops during SSR.
// Guard animation-dependent logic with isPlatformBrowser checks
// if it must not run on the server.

The ten parts cover the basic trigger, the enter/leave pair, the animation callback, the keyframes animation, the disabled binding, the void state aliases, the wildcard ordering trap, the increment/decrement aliases, the height-auto trap, and the SSR caveat.


Quick Reference

The Core Functions

FunctionPurposeKey Parameters
trigger()Names an animation and contains states/transitionsname, definitions[]
state()Defines named styles for a state (persists)name, style()
style()Wraps CSS properties for animation{ cssProp: value }
transition()Connects states with animation steps (temporary)expression, steps[]
animate()Defines timing and optional stylestimings, styles?
keyframes()Multi-step animation with offsetsstyle[] with offset

The Timing Formats

FormatExampleMeaning
Numberanimate(500)500 milliseconds
Duration only'500ms'Runs for 500ms
Duration + delay'0.2s 100ms'Waits 100ms, runs 200ms
Full'0.2s 100ms ease-out'Includes easing curve

The State Expressions

ExpressionMeaning
open => closedUnidirectional transition
open <=> closedBidirectional transition
:enter / void => *Element enters DOM
:leave / * => voidElement leaves DOM
* => closedAny state to closed
* => *Any state to any state
:incrementNumeric value increases
:decrementNumeric value decreases

The Reserved States

StateMeaning
voidElement not in DOM
*Default/fallback state

The Callback Events

EventFires When
@trigger.startAnimation phase begins
@trigger.doneAnimation phase ends

Best Practices

✅ Do This:

// Use wildcard height for content-driven sizing
state('expanded', style({ height: '*' }))                    // ✅
// Define entering styles before animate()
transition(':enter', [
  style({ opacity: 0 }),                                     // ✅
  animate('300ms', style({ opacity: 1 })),
])
// Place specific transitions before wildcards
transition('open => closed', [animate('0.2s')]),             // ✅
transition('* => *', [animate('1s')]),
// Use @.disabled for E2E tests
<div [@.disabled]="isTestMode">                              // ✅
// Use callbacks for chaining logic
(@fadeInOut.done)="onAnimationComplete($event)"              // ✅
// Use meaningful trigger and state names
trigger('expandCollapse', [                                  // ✅
  state('expanded', ...),
  state('collapsed', ...),
])

❌ Don’t Do This:

// Don't hardcode pixel heights for variable content
state('expanded', style({ height: '200px' }))                // ❌
// Don't forget the starting style in :enter
transition(':enter', [
  animate('300ms', style({ opacity: 1 })),                   // ❌ jumps
])
// Don't rely on :leave when parent is removed
// The leave animation will not fire.                        // ❌
// Don't animate to/from auto height
state('expanded', style({ height: 'auto' }))                 // ❌
// Don't use * => * before specific transitions
transition('* => *', [animate('1s')]),                       // ❌
transition('open => closed', [animate('0.2s')]),

Common Pitfalls

PitfallProblemSolution
Animation doesn’t runBound value didn’t changeBind to changing value (counter, length)
Element jumps before animatingMissing starting style() in :enterAdd initial style before animate()
:leave never firesElement removed with parentEnsure element exits DOM on its own
Child animations blockedParent animation gets priorityUse animateChild() or @.disabled
Timing string syntax errorIncorrect units or orderUse 'duration delay easing' format
Height animation from autoCSS can’t animate to/from autoUse height: '*' in Angular state
Wildcard overrides specificOrder of transitions wrongPlace specific transitions first
Animations run on server:enter/:leave in SSRGuard with isPlatformBrowser

Real-World Examples

1. Accordion Panel

trigger('accordion', [
  state('open', style({ height: '*', opacity: 1 })),
  state('closed', style({ height: '0px', opacity: 0 })),
  transition('open <=> closed', animate('300ms ease-in-out')),
])

2. Toast Notification

trigger('toast', [
  transition(':enter', [
    style({ transform: 'translateX(100%)' }),
    animate('250ms ease-out', style({ transform: 'translateX(0)' })),
  ]),
  transition(':leave', [
    animate('200ms ease-in', style({ transform: 'translateX(100%)' })),
  ]),
])

3. Modal Dialog

trigger('modal', [
  transition(':enter', [
    style({ opacity: 0, transform: 'scale(0.8)' }),
    animate('200ms ease-out', style({ opacity: 1, transform: 'scale(1)' })),
  ]),
  transition(':leave', [
    animate('150ms ease-in', style({ opacity: 0, transform: 'scale(0.8)' })),
  ]),
])

4. Fade-In List Item

trigger('listItem', [
  transition(':enter', [
    style({ opacity: 0, transform: 'translateY(-10px)' }),
    animate('200ms ease-out', style({ opacity: 1, transform: 'none' })),
  ]),
])

5. Route Fade

trigger('routeFade', [
  transition('* => *', [
    style({ opacity: 0 }),
    animate('300ms', style({ opacity: 1 })),
  ]),
])

6. Toggle Switch Knob

trigger('knob', [
  state('off', style({ transform: 'translateX(0)' })),
  state('on', style({ transform: 'translateX(20px)' })),
  transition('off <=> on', animate('200ms cubic-bezier(0.4, 0, 0.2, 1)')),
])

7. Shake on Error

trigger('shake', [
  transition('* => *', [
    animate('400ms', keyframes([
      style({ transform: 'translateX(0)', offset: 0 }),
      style({ transform: 'translateX(-10px)', offset: 0.2 }),
      style({ transform: 'translateX(10px)', offset: 0.4 }),
      style({ transform: 'translateX(-10px)', offset: 0.6 }),
      style({ transform: 'translateX(0)', offset: 1 }),
    ])),
  ]),
])

8. Loading Spinner

trigger('spin', [
  transition('* => *', [
    animate('1s linear', keyframes([
      style({ transform: 'rotate(0deg)', offset: 0 }),
      style({ transform: 'rotate(360deg)', offset: 1 }),
    ])),
  ]),
])

9. Image Cross-Fade

trigger('crossFade', [
  transition(':enter', [
    style({ opacity: 0 }),
    animate('500ms', style({ opacity: 1 })),
  ]),
  transition(':leave', [
    animate('500ms', style({ opacity: 0 })),
  ]),
])

10. Sidebar Slide

trigger('sidebar', [
  state('closed', style({ transform: 'translateX(-100%)' })),
  state('open', style({ transform: 'translateX(0)' })),
  transition('closed <=> open', animate('300ms ease-in-out')),
])

Visual: State Machine Model

┌──────────────────────────────────────────────┐
│  STATE MACHINE                               │
│                                              │
│  [closed] ──transition('closed => open')──>  │
│      ↑                                       │
│      │                                       │
│      └──────transition('open => closed')─────┘
│                                              │
│  Each state = endpoint styles                │
│  (persist after animation)                   │
│                                              │
│  Each transition = animated journey          │
│  (styles apply during motion only)           │
│                                              │
└──────────────────────────────────────────────┘

Visual: Enter/Leave Lifecycle

┌──────────────────────────────────────────────┐
│  ENTER/LEAVE LIFECYCLE                       │
│                                              │
│  :enter                    :leave            │
│  void ──> *                * ──> void        │
│  │                         │                 │
│  ├─ style() starting       ├─ animate()      │
│  ├─ animate()              └─ style() ending │
│  └─ style() ending                           │
│                                              │
│  Added to DOM              Removed from DOM  │
│  ALWAYS runs               Only runs if      │
│                            element exits     │
│                            alone             │
│                                              │
└──────────────────────────────────────────────┘

Visual: Animation Execution Order

┌──────────────────────────────────────────────┐
│  EXECUTION ORDER                             │
│                                              │
│  :enter called after:                        │
│    ├─ ngOnInit                               │
│    └─ first change detection cycle           │
│                                              │
│  :leave called after:                        │
│    └─ ngOnDestroy                            │
│                                              │
│  PARENT-CHILD:                               │
│    Parent animation ──blocks──> Child        │
│    (unless queried with animateChild())      │
│                                              │
└──────────────────────────────────────────────┘

Visual: Transition Matching Order

┌──────────────────────────────────────────────┐
│  TRANSITION MATCHING                         │
│                                              │
│  transitions: [                              │
│    1. 'open => closed'    ← matched first    │
│    2. '* => *'            ← fallback         │
│  ]                                           │
│                                              │
│  ❌ WRONG ORDER:                             │
│  transitions: [                              │
│    1. '* => *'            ← catches all      │
│    2. 'open => closed'    ← never reached    │
│  ]                                           │
│                                              │
│  Specific first, wildcard last.              │
│                                              │
└──────────────────────────────────────────────┘

Summary

ItemValue
Package@angular/animations
StatusDeprecated (v20.2), removal planned v23
RecommendationNative CSS for new code
Core functionstrigger, state, style, transition, animate
Enter alias:enter = void => *
Leave alias:leave = * => void
Height wildcardheight: '*' for content-driven sizing
Callback events@trigger.start, @trigger.done
Disable binding[@.disabled]="true"
Execution order:enter after ngOnInit, :leave after ngOnDestroy
Parent-child ruleParent blocks child unless animateChild()

Key takeaways:

  • States define endpoints, transitions define the journey. state() styles persist; transition() styles only apply during motion. Without a transition, state changes are instant .
  • The :enter and :leave aliases handle DOM lifecycle. :enter always runs when an element is added. :leave only runs if the element exits the DOM on its own — not if it’s removed with its parent .
  • Timing strings follow 'duration delay easing'. A plain number is milliseconds. Strings add units, delay, and easing curves like cubic-bezier() .
  • Transitions match in order. Place specific transitions before wildcards, or the wildcard will catch every change and your specific transition will never run.
  • height: '*' resolves to content height. CSS cannot animate to or from auto, but Angular’s wildcard height measures the actual content and animates to it .
  • Parent animations block children. Unless you explicitly query with animateChild(), child triggers wait for the parent to finish .
  • @.disabled turns animations off. Use it for end-to-end testing or when you need to skip motion entirely without removing the animation definitions.
  • Animation callbacks fire per phase. Bind @trigger.start and @trigger.done to react when motion begins and ends, chain logic, or log performance .
  • The animations package is deprecated. New code should use native CSS. Existing code should plan a migration.

Remember: Angular animations turn component state into motion. You declare states, connect them with transitions, bind triggers in templates, and Angular handles the interpolation, scheduling, and cleanup. The model is explicit: nothing animates unless you say so, and the journey between states is separate from the states themselves. Master the primitives first — trigger, state, transition, animate — then move on to coordination, staggering, and route transitions.

Angular, animations, triggers, transitions


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!