HarmonyOS Animation Basics: ArkUI Transition and Animator API Explained
ArkUI gives you three different ways to animate something. They overlap enough that it’s genuinely confusing which one to reach for. After shipping a couple of HarmonyOS pages with non-trivial motion, here’s the mental map I wish I had on day one.
The three tools are:
animateTo(property animation) — change a property, wrap the change inanimateTo, ArkUI tweens it. The 80% case.transition(appear / disappear / shared element) — declarative, attached to a component, fires when the component enters or leaves the tree.Animatorclass — a low-level value generator. You don’t get a free property animation; you getonFrame(value => ...)and you wire it up yourself. Use when nothing else is flexible enough.
Let’s go through each, in the order you’ll actually need them.
animateTo: the workhorse
If you know animate* in CSS or animateXxxAsState in Compose, this is that. You wrap a state mutation in animateTo({ duration, curve, ... }, () => { ... }) and ArkUI interpolates the visible properties between the old and new values.
@Entry @Componentstruct Pulse { @State scale: number = 1;
build() { Column() { Text('Tap me') .fontSize(24) .scale({ x: this.scale, y: this.scale }) .onClick(() => { animateTo({ duration: 200, curve: Curve.EaseOut }, () => { this.scale = this.scale === 1 ? 1.2 : 1; }); }); } .height('100%').width('100%').justifyContent(FlexAlign.Center) }}Three points worth knowing:
- Only animatable properties tween. Width, height, opacity, position, rotation, scale, translation, padding, margin, color (in some cases) — yes. Layout direction or
flexsemantics — no. - The mutation must happen inside the closure. Mutating the state outside
animateToand then callinganimateToempty does nothing. The framework diffs the snapshot taken at entry vs the snapshot at exit. - Curves are picked from the
Curveenum (Linear,Ease,EaseIn,EaseOut,EaseInOut,Sharp,Friction, etc.). For physics-y motion, usecurves.springMotion()(note the lowercasecurves— it’s a separate import from@ohos.curves).
import curves from '@ohos.curves';
animateTo({ curve: curves.springMotion(0.4, 0.7) }, () => { this.offsetY = -120;});transition: enter and exit animations
transition is for when a component appears or disappears from the tree. Think AnimatedVisibility in Compose, or React’s <Transition>. You attach it once, and ArkUI plays the animation automatically when the component is mounted/unmounted via conditional rendering.
@Entry @Componentstruct Sheet { @State open: boolean = false;
build() { Stack({ alignContent: Alignment.Bottom }) { Column() { Button(this.open ? 'Close' : 'Open') .onClick(() => { this.open = !this.open }); } .height('100%') .width('100%') .justifyContent(FlexAlign.Center);
if (this.open) { Column() { Text('I slide up from the bottom').padding(24); } .width('100%') .height(220) .backgroundColor('#3478f6') .borderRadius(20) .transition(TransitionEffect.move(TransitionEdge.BOTTOM) .animation({ duration: 240, curve: Curve.EaseOut })); } } }}The TransitionEffect API composes well: you can chain .combine(...) to mix opacity + slide + scale. Read the animation-and-gesture reference for the full vocabulary, but a useful starter set is:
TransitionEffect.OPACITY— fadeTransitionEffect.move(edge)— slide from edgeTransitionEffect.scale({ x, y })— scale fromTransitionEffect.translate({ x, y })— translate from offset
geometryTransition is the shared-element cousin. You tag two components with the same key in two states and ArkUI animates between them. Powerful but easy to over-use; reach for it for hero images and detail-page expansions, not for every small interaction.
Animator: the manual transmission
Animator is what you use when neither of the above gives you what you need. The classic case: animating a value that isn’t a UI property — a Canvas drawing parameter, a physics simulation, an audio gain. You ask the system for a tween that runs on a frame loop and gives you the current value.
import { Animator, AnimatorResult } from '@ohos.animator';
@Entry @Componentstruct AnimatorDemo { @State waveOffset: number = 0; private animator: AnimatorResult | null = null;
aboutToAppear() { this.animator = Animator.create({ duration: 1500, easing: 'linear', delay: 0, fill: 'forwards', direction: 'normal', iterations: -1, // infinite begin: 0, end: 100, }); this.animator.onFrame = (value: number) => { this.waveOffset = value; }; this.animator.play(); }
aboutToDisappear() { this.animator?.cancel(); }
build() { Text(`Offset: ${this.waveOffset.toFixed(1)}`) }}Three things that bit me when I first used it:
- Always cancel in
aboutToDisappear. Otherwise you leak a frame callback and the page keeps “running” after you navigate away. Battery and CPU both notice. iterations: -1is “infinite,” notInfinity. The TypeScript types accept any number; pass-1explicitly.onFrameruns on the UI thread. Heavy work inside it freezes scrolling. If you’re computing something expensive, do it once inonFrameand let@Statepropagate.
A worked example: a “loading dot” ripple
Let’s combine the three. The component shows three dots that pulse with a stagger, plus a fade-in/out when shown/hidden.
@Componentstruct Dot { @Prop delay: number; @State scale: number = 1;
aboutToAppear() { setTimeout(() => this.loop(), this.delay); }
loop() { animateTo({ duration: 600, curve: Curve.EaseInOut, onFinish: () => this.loop(), }, () => { this.scale = this.scale === 1 ? 1.6 : 1; }); }
build() { Circle({ width: 10, height: 10 }) .fill('#3478f6') .scale({ x: this.scale, y: this.scale }); }}
@Entry @Componentstruct LoadingRipple { @State visible: boolean = true;
build() { Column({ space: 24 }) { Button(this.visible ? 'Hide' : 'Show') .onClick(() => { this.visible = !this.visible });
if (this.visible) { Row({ space: 16 }) { Dot({ delay: 0 }) Dot({ delay: 150 }) Dot({ delay: 300 }) } .transition(TransitionEffect.OPACITY .animation({ duration: 200, curve: Curve.EaseOut })); } } .padding(32) }}Notice the layering:
animateTodrives each dot’s pulse — a property animation.transitionhandles the fade in/out of the whole row whenvisibletoggles.Animatorisn’t needed here. If we wanted the dots to ride a sine wave instead of a binary scale, that’s where it would come in.
Common gotchas
- Animations don’t fire on first render.
animateToonly animates changes. If you want an enter animation, usetransition(or initialize state to one value and mutate to another inaboutToAppear). - Layout-changing properties cause reflow. Animating
widthis fine but expensive. Preferscaleortranslatefor cheap motion. - Don’t nest
animateToblindly. CallinganimateToinsideanimateTo’s closure leads to surprising behavior. Each call creates an animation context; chain them withonFinishinstead. onFinishdoesn’t fire if the animation is interrupted. If you start a new animation that overrides the previous one’s target, the previousonFinishis dropped. Don’t rely on it for cleanup; useaboutToDisappearfor cancellation.- Spring curves can overshoot.
springMotionis gorgeous but it crosses your target value. If you’re animating opacity or anything clamped to [0,1], useEaseOutinstead.
When to reach for what
| You want… | Use |
|---|---|
| A button to scale on tap | animateTo |
| A panel to slide up when shown | transition(TransitionEffect.move(...)) |
| A list item to expand smoothly when tapped | animateTo on height (or use Tween size) |
| A value to drive a Canvas frame-by-frame | Animator or requestAnimationFrame |
| Hero image animating between two pages | geometryTransition |
| Physics-feel “bouncy” reveal | animateTo with curves.springMotion() |
Closing thought
ArkUI animation is well-designed but the surface area is wide. Pick animateTo first; only graduate to transition and Animator when animateTo clearly can’t express what you want. That order is also the order from cheapest to most expensive in terms of CPU and complexity.
If you find yourself reaching for Animator for a UI button, stop and re-read this post. It’s almost certainly the wrong tool.
Related references
docs/ui/animation-and-gesture— the full ArkUI animation vocabularydocs/ui/visual-effects-recipes— spring motion, shared transitions, glow, glassmorphismdocs/ui/ui-implementation-rules— MVP-first implementation rules