HypenHypen
Guide

Animation

Declarative animation in Hypen — transitions, enter/exit, layout, presets, states, shared elements, scrubbing, and completion events

Animation

Hypen animates declaratively: you attach animation applicators to nodes, and the engine ships that intent to the renderer, which plays the motion natively — CSS transitions on the web, SwiftUI's implicit animation on iOS, Compose animation on Android, tick-based interpolators on Canvas and desktop. A channel a renderer doesn't play simply snaps to the correct final state, so your UI stays correct on every platform.

The surface is ten applicators plus one event applicator and one event argument:

ConstructWhat it animates
.transition()Future prop changes on the node
.enter() / .exit()The node appearing / being removed
.layout()Keyed list items moving (FLIP)
.animate()Built-in preset timelines (spin, pulse, shimmer, shake)
.states { }Coordinated multi-prop looks driven by one state value
.sharedElement()The same visual element continuing across a navigation
.scrub() / .settle()Dragging or scrolling between two .states poses
.motion(essential)Opt a node out of reduced-motion snapping
.onAnimationComplete()Dispatches an action when a playback settles
animate: (on event applicators)Everything a single action's state changes touch

Quick Start

// Animate future prop changes on this node
Text("@{state.score}")
    .fontSize("@{state.emphasized ? 32 : 18}")
    .transition(200, easeOut)

// Animate appearance and disappearance
If(condition: @state.showToast) {
    Row { Text("Saved!") }
        .enter(slide, fade, from: bottom)
        .exit(fade, duration: 150)
}

// Animate list reorders (FLIP)
ForEach(@state.items) { item ->
    Row { Text("@{item.title}") }
        .key("@{item.id}")
        .layout(spring)
}

// Ambient motion: looping and one-shot presets
Spinner {}.animate(spin)
Badge("LIVE").animate(pulse)
Row {}.animate(shimmer)                          // skeleton loading sweep

// Named visual states: one state path drives coordinated multi-prop looks
Image(src: "@{state.cover}")
    .width(100)
    .cornerRadius(4)
    .states(@state.cardState, transition: spring) {
        onState(collapsed).width(48).cornerRadius(8)
        onState(expanded).width(240).cornerRadius(16)
    }

// Shared-element continuity: the same key on two routes = one element across navigation
Image(src: "@{item.coverUrl}")
    .sharedElement("cover-@{item.id}")

// React when an animation finishes
Toast {}
    .enter(slide, from: bottom)
    .onAnimationComplete(@actions.toastSettled)  // payload: { animation: "enter" }

Syntax rule for the whole family: animation arguments are flat. Presets and curves are bare tokens, options are named arguments — .enter(slide, fade, from: bottom) is valid, slide(from: bottom) or spring(bouncy) is not. Invalid arguments never break the render; they warn and fall back to the channel's defaults. State bindings inside animation arguments are ignored with a warning — with one exception, the .sharedElement key, where bindings are the point.

Animating Prop Changes — .transition()

Declare once on a node that property changes should animate. Every later change to an animatable property (a state binding update, a conditional value flip) interpolates instead of snapping. A new value arriving mid-flight retargets from the current visual value.

.transition()                                    // defaults: 200ms, easeOut
.transition(200, easeOut)                        // positional: number → duration (ms), token → curve
.transition(duration: 300, curve: spring, delay: 50)
.transition(props: [opacity, translateY])        // scope to specific props
  • A positional number sets the duration in milliseconds; a bare token sets the curve.
  • props: limits the transition to the listed properties (entries outside the animatable whitelist are dropped with a warning). Omit it to animate every animatable property.
  • The initial render never animates — transition styles apply before first paint.

Platform Support: Web, Android, iOS, Desktop, Canvas

Appearance and Removal — .enter() / .exit()

.enter() plays when a node is inserted (an If turning true, a ForEach item added). .exit() plays when it is removed — the renderer defers the actual teardown until the exit settles, so elements fade or slide out instead of vanishing.

.enter(fade)                                     // single preset
.enter(slide, fade, from: bottom)                // presets compose; direction is a named arg
.enter(scale, duration: 250, curve: easeOut)
.exit(fade, duration: 150)
.exit(slide, to: trailing)
  • Presets: fade, slide (24px offset), scale (from 0.95). They compose — .enter(slide, fade) slides and fades together. With no preset, fade is the default.
  • Direction is a named argument — from: on .enter, to: on .exit — with values top, bottom, leading, trailing (leading/trailing are RTL-aware).
  • Defaults: enter {200ms, easeOut}, exit {150ms, easeIn}.
  • Enter plays only for nodes that appear after the initial render; the first paint never cascades enter animations, and a route restored from the Router cache reappears instantly.
  • An exit-animating subtree is inert: it ignores pointer events and its actions no longer dispatch. It still occupies layout until the exit settles.
  • Children removed with their parent follow the parent's exit — a child's own .exit does not play.

Rapid toggling caveat: toggling an If off and back on before the exit finishes shows both nodes for a moment — the old node plays its exit while the new one plays its enter (the same behavior as Framer Motion's AnimatePresence). If that reads wrong for your UI, gate the re-show on the exit's .onAnimationComplete, debounce the driving state, or keep one keyed node mounted and flip it with .states instead.

Platform Support: Web, Android, iOS, Desktop, Canvas

List Reorders — .layout()

When a keyed ForEach item moves, .layout() animates it from its old position to its new one (a FLIP animation) instead of jumping.

.layout()                                        // defaults: 300ms, spring
.layout(spring)
.layout(duration: 400, curve: easeInOut)

If the same update both moves and removes a node, the exit animation wins. On the web, siblings that carry .layout also animate the shift when a neighbor is removed.

Platform Support: Web, Desktop (Android, iOS, and Canvas snap — moves jump)

Preset Timelines — .animate()

.animate() plays a built-in keyframe timeline — ambient, decorative motion that isn't driven by a state change: spinners, live badges, skeleton shimmers, error shakes. The first positional token names the preset; modifiers are named-only.

.animate(spin)                                   // per-preset defaults
.animate(pulse, duration: 800, repeat: 3, curve: easeInOut)
.animate(shake, delay: 100)
PresetMotionDurationRepeatCurve
pulseopacity breathes 1 → 0.5 → 11200msloopeaseInOut
spinfull 360° rotation800mslooplinear
shimmergradient highlight sweeps across1500mslooplinear
shakehorizontal wiggle400ms1easeInOut
  • repeat: is the token loop or a positive integer.
  • An unknown preset omits the animation entirely (with a warning) — every other invalid argument falls back to the preset's default.
  • There is no author-defined keyframe DSL and no when: trigger — the built-in presets are the entire timeline surface. For "play on becoming true", put the preset on a node inside an If; it plays when the node enters:
If(condition: @state.error) {
    Card { Text("@{state.error}") }.animate(shake)
}
  • On a route restored from the Router cache, looping presets resume but finite-repeat presets do not replay.

Platform Support: Web, Android, iOS (shake one-sided), Desktop (shimmer snaps), Canvas (shimmer snaps)

Named Visual States — .states { }

.states gives a node named looks selected by one state path — the coordinated-transition construct (Compose's updateTransition, Framer's variants). Instead of scattering ternaries across props, declare each pose once and flip a single state value; every overridden prop travels together, under one shared timing.

Image(src: "@{state.cover}")
    .width(100)
    .cornerRadius(4)
    .states(@state.cardState, transition: spring, duration: 250) {
        onState(collapsed).width(48).cornerRadius(8).opacity(0.9)
        onState(expanded).width(240).cornerRadius(16).tw("shadow-lg")
    }

The header:

  • The first positional argument must be a state reference (@state.cardState) — anything else warns and the applicator is ignored.
  • Named arguments: transition: (a curve token — note the name, not curve:), duration: and delay: in milliseconds. Defaults: easeOut, 250ms, no delay.
  • One .states per node.

Poses: the block contains only onState(<label>) entries; the pose's props chain as applicators on the head. Pose applicators run through the same machinery as normal chains — .tw(...), directional forms, and breakpoint/interaction variants all work per pose. Three things are excluded inside a pose (warn + drop): animation applicators, .bind, and event applicators. Pose values must be static — no @{state.*} bindings inside a pose.

Matching: the value at the driving path is stringified and matched against the labels (state 2 matches onState("2")). A matched pose's props override the node's base chain. No match — unknown label, missing path — falls back to the node's base values; a pose-only prop is removed entirely.

Timing: .states synthesizes a transition for the node scoped to the overridden animatable props, so pose flips glide with zero extra declarations. An explicit .transition(...) on the same node wins over the synthesized one. Overridden props outside the animatable whitelist still switch — they just snap.

Prefer a plain conditional prop ("@{state.x ? 32 : 18}") when exactly one prop changes; reach for .states when a look is several props that must move as one.

Platform Support: All platforms show the correct pose (pose flips are ordinary prop updates). Pose flips glide on Web, Android, iOS, Desktop, and Canvas.

Cross-Route Continuity — .sharedElement()

.sharedElement(key) tags a node as the same visual element across a navigation — the list-thumbnail-becomes-detail-hero effect. When a route change removes a keyed node and the next screen introduces a node with the same key, the incoming node animates from where the outgoing one sat instead of appearing in place.

// List route
Image(src: "@{item.coverUrl}")
    .sharedElement("cover-@{item.id}")

// Detail route
Image(src: "@{state.restaurant.coverUrl}")
    .sharedElement("cover-@{state.restaurant.id}", curve: spring, duration: 350)
  • The key is the first positional argument and must be a string. It may contain template bindings — this is the one animation argument where bindings are legal, because identity is data.
  • curve: and duration: are named-only, defaults {spring, 350ms}. The incoming (destination) node's timing plays.
  • A key with no partner, or an element that can't be measured, degrades silently to a plain navigation — never an error. In dev, a key that matches nothing warns once so typos are catchable.
  • A matched node's own .enter is suppressed for that navigation — one motion, not two.
  • Navigating again mid-flight retargets from wherever the element currently is on screen.
  • v1 is transform-only continuity: position and size interpolate; corner radius and opacity don't, and there is no content crossfade. Pairs that mostly match visually look seamless.

Platform Support: Web, Desktop (Android, iOS, and Canvas show a plain navigation)

Gesture and Scroll Bindings — .scrub() / .settle()

.scrub() binds a node's position between two of its .states poses to a continuous input — a finger dragging a bottom sheet, a scroll offset collapsing a header. The interpolation runs entirely inside the renderer, frame by frame; the engine hears exactly one thing, at the end: .settle() writes the winning pose label to a state path as one ordinary state write.

// Upward-opening bottom sheet: 400px of upward finger travel = closed → open
Sheet { ... }
    .states(@state.sheetPhase) {
        onState(closed).translateY(400)
        onState(open).translateY(0)
    }
    .scrub(from: closed, to: open, axis: y, over: [0, -400])
    .settle(curve: spring, duration: 300, bind: @state.sheetPhase)

// Collapsing header driven by the scroll container's offset
Header { ... }
    .states(@state.headerMode) {
        onState(expanded).height(120)
        onState(collapsed).height(48)
    }
    .scrub(from: expanded, to: collapsed, source: scroll, axis: y, over: [0, 120])
    .settle(bind: @state.headerMode)
  • .scrub(from:, to:, source:, axis:, over:, rubberBand:, of:)from:/to: (required) are two pose labels from the same node's .states block; source: is gesture (default) or scroll; axis: is x or y; over: (required) is the directed input range [inputAtProgress0, inputAtProgress1] in pixels — an upward-opening sheet is over: [0, -400]; rubberBand: is the resistance factor beyond the range (default 0.4); of: names a scroll container by its id prop.
  • .settle(curve:, duration:, bind:) — the release animation's timing (defaults {spring, 300ms}) and the required bind: state path that receives the winning label.
  • Taps pass through — a gesture claims the pointer only after ~6px of travel, so a plain tap is a total no-op and clicks on children are unaffected.
  • Drags start from where the element is: a settled-open sheet re-drags from progress 1, a sheet caught mid-settle is grabbed where it is.
  • On release, the velocity-projected progress picks the nearest pose, the settle animation plays, and the label is written to bind: — your module sees a normal state change, so a machine that also flips the same path from buttons keeps working unchanged.
  • Scroll sources track continuously and write when progress rests at an endpoint.
  • Requirements: a .states block declaring both labels and a .settle(bind:) on the same node. Any violation warns once and the node degrades to plain .states behavior.
  • Under reduced motion, dragging works unchanged (direct manipulation is the user's own hand); only the release settle snaps.

Platform Support: Web, Desktop (Android, iOS, and Canvas ignore scrubbing — the node still flips poses when the bound state changes by other means)

Completion Events — .onAnimationComplete()

.onAnimationComplete(@actions.name) dispatches an action when an animation on the node finishes — the piece that lets a module sequence motion without setTimeout guesses.

.onAnimationComplete(@actions.animationDone)
.onAnimationComplete(@actions.animationDone, source: "card")   // extra args merge into the payload
PlaybackPayload
Finite .animate preset{ animation: "shake" } (the preset name)
.enter{ animation: "enter" }
.exit (just before teardown){ animation: "exit" }
.states pose transition{ animation: "states", state: "expanded" } (the matched label)
.sharedElement FLIP{ animation: "sharedElement" }

A completion fires only when a playback settles naturally. Interrupted or superseded playbacks, reduced-motion skips, looping presets, and .states falling back to the base pose all fire nothing — which removes most completion races by construction. Handlers should check the payload and drop completions for phases they've already left.

The intended pattern is a module-resident state machine — the DSL declares the poses, the module owns the transition logic:

Button("@actions.save") {
    Text("@{state.savePhase == 'done' ? 'Saved!' : 'Save'}")
}
.width(160)
.backgroundColor("#3b82f6")
.states(@state.savePhase, transition: easeOut, duration: 300) {
    onState(saving).opacity(0.6)
    onState(done).backgroundColor("#22c55e").width(48).cornerRadius(24)
}
.onAnimationComplete(@actions.animationDone)
export default app
  .defineState<{ savePhase: string }>({ savePhase: "idle" })
  .onAction("save", async ({ state }) => {
    state.savePhase = "saving";
    await save();
    state.savePhase = "done";            // pose flip → transition plays → completion fires
  })
  .onAction("animationDone", ({ action, state }) => {
    if (action.payload.animation === "states" && action.payload.state === "done") {
      state.savePhase = "idle";          // no `idle` pose declared → back to the base look
    }
  });

Treat completions as motion choreography, not the only path to a correct end state: on a renderer that snaps a channel, the machine still lands in a sensible pose — it just skips the timed hop.

Platform Support: Web and Desktop dispatch all five firing points; Canvas, iOS, and Android dispatch the first four (no sharedElement)

Animating the Cause — animate:

Everything above animates the node. animate: animates the cause: the same state change glides when a user tap produced it and snaps when a websocket refresh did — SwiftUI's withAnimation idea on Hypen's dispatch pipeline.

Button("@actions.toggleCart") { Text("Cart") }
    .onClick(@actions.toggleCart, animate: spring)

Row {}.onClick(@actions.expand, animate: {curve: easeOut, duration: 400})
  • Any event applicator accepts animate: — a bare curve token (means {curve, 250ms}) or a spec map (curve:, duration:, delay:, props:).
  • The handler's synchronous mutations glide with the spec, on every affected node, whether or not the node declares its own .transition. Mutations after an await are data, and data snaps:
.onAction("toggleCart", async ({ state }) => {
  state.cartOpen = !state.cartOpen;   // glides with the animate: spec
  await syncCart();
  state.lastSynced = Date.now();      // after the await — snaps
});
  • State changes the action didn't cause (websocket pushes, timers) keep snapping — even if they land mid-glide.
  • Precedence: structural playbacks (enter/exit/FLIP/shared-element) > transaction > node .transition > snap.
  • Only the event applicator's own named animate: argument is reserved — a field named animate inside a payload object is ordinary user data.
  • Stamping is currently TypeScript-host-only (browser engine and Node/Bun servers); a TS-hosted stamp reaches every connected renderer, mobile included. On Go and Kotlin hosts the dispatch works normally but the flush snaps.

Platform Support: Web, Desktop, Android, iOS renderers honor the stamp (TypeScript hosts only)

Vocabulary

Curves: linear, easeIn, easeOut, easeInOut, springspring is a fixed overshoot curve (cubic-bezier(0.34, 1.56, 0.64, 1)), identical on every platform.

Enter/exit presets: fade, slide, scale. Directions: top, bottom, leading, trailing (RTL-aware).

Defaults:

ApplicatorDurationCurveNotes
.transition()200mseaseOutall animatable props
.enter()200mseaseOutpresets default to fade
.exit()150mseaseInpresets default to fade
.layout()300msspring
.animate()per presetper presetsee the preset table
.states()250mseaseOutcurve is named transition:
.sharedElement()350msspringkey is the first positional; no delay:
.settle()300msspringbind: required; no delay:

Animatable Properties

Only whitelisted properties animate — the set every renderer can interpolate consistently. Everything else (display, text content, .tw() classes) switches instantly.

opacity · translateX · translateY · scale · rotate · color · backgroundColor · borderColor · cornerRadius · padding (+ 6 directional forms) · margin (+ 6 directional forms) · width · height · gap · fontSize

Reduced Motion

When the platform reports reduced motion (prefers-reduced-motion on the web, accessibilityReduceMotion on iOS, "Remove animations" on Android), everything snaps with zero author code: transitions neutralize, enters and layout animations skip, exits remove immediately, presets don't play.

The rare animation that carries meaning — a progress indicator, a status pulse — can opt out per node:

Spinner {}.animate(spin).motion(essential)

essential is the only valid token. A flagged node animates exactly as if the preference were off; everything without the flag keeps snapping. Shared-element FLIPs stay skipped everywhere — cross-route continuity is inherently decorative. Use it sparingly: it exists for meaning-bearing motion, not for overriding a user preference wholesale.

Platform Support Summary

ChannelWeb (DOM)DesktopiOSAndroidCanvas 2D
.transitionYesYesYesYesYes (cornerRadius snaps)
.enter / .exitYesYesYesYesYes
.layoutYesYesSnapSnapSnap
.animateYesYes (no shimmer)YesYesYes (no shimmer)
.statesYesYesYesYesYes
.sharedElementYesYesSnapSnapSnap
.scrub / .settleYesYesSnapSnapSnap
.onAnimationComplete5 firing points5 firing points444
animate:YesYesYesYesYes

"Snap" always means the UI is identical minus the motion — nothing errors, and the correct final state always shows. The full per-renderer capability matrices (including recorded narrowings like iOS's one-sided shake or desktop's uniform scale) live in the SDK reference: hypen-web/docs/animation.md.

Legacy: the .transition("...") string form

The old web-only form passed a CSS shorthand straight through:

Text("Old style").transition("opacity 0.3s ease")   // deprecated

It still works on the web renderer but logs a deprecation warning and means nothing on any other platform. Prefer the portable form:

Text("New style").transition(duration: 300, curve: easeOut, props: [opacity])