Osy#the first language built for agents
Agents firstAgentic appsWorkflowsDurable Execution — built inSecurityTestingThe editorThe UI modelOne program

Reference / UI

animation — looping motion with no destination state

animation Name { Duration = "1.5s"; Easing = EaseInOut; Repeat = Infinite; 0% { … } 100% { … } }

An `animation` block declares reusable, looping motion — a shimmer, a pulse, an indeterminate progress hint. Its keyframe stops are written with the ordinary style props, so they are checked at compile time and can read theme tokens. The timing lives on the declaration, so a use site is a single `animation: Name` reference.

stable7 examples compiled by CIuianimationmotionstyling

Summary#

An animation declares motion that loops with no destination state — a skeleton shimmer, a pulse on a live indicator, an indeterminate progress hint while a long job runs, or a highlight that decays back after a row changes.

That is the gap it fills. A transition already covers every A→B state change: hover a button and its tint moves, toggle a drawer and it slides. But some motion has no "to" value — a reconnecting spinner runs until the connection comes back, and a "this row just changed" flash has to fade away by itself. Those are events, not states, so there is nothing for a transition to move toward.

animation Shimmer {
  Duration = "1.5s";
  Easing = EaseInOut;
  Repeat = Infinite;
  0%   { Opacity = 0.4; }
  100% { Opacity = 1; }
}

A keyframe stop's body is the ordinary style-prop vocabulary — the same names a variants block uses. So a stop is checked at compile time, a misspelled prop is an error rather than a silently dead line, and a stop can read a theme token like any other style.

Signature#

animation <Name> {
  Duration = "<time>";                 // optional — "1.5s" or "600ms"
  Easing   = <Linear|Ease|EaseIn|EaseOut|EaseInOut>;   // optional
  Repeat   = <Infinite|<count>>;       // optional — Infinite, or a whole number

  <percent>% { <StyleProp> = <value>; … }    // one block per stop
  from { … }                                 // an alias for 0%
  to   { … }                                 // an alias for 100%
}

Description#

Where an animation is declared — top level, not in a theme#

An animation is app-global, declared at the top level beside entity, enum and component. It is not part of a theme: a theme holds tokens, which are single values, while an animation is a rule with structure of its own. And it is not declared inside a component, because motion like a shimmer is reused across many screens — declaring it once is the point.

Where do Duration, Easing and Repeat go?#

Duration, Easing and Repeat are written once, where the animation is defined — not at every place that uses it. A use site is therefore a single reference, and reading it tells you the whole story:

component Skeleton() {
  variants { base { Bg = Colors.Surface; Animation = Shimmer; } }
  render { Box(h: "16px"); }
}

All three settings are optional; anything you omit takes the platform's default (run once, at an even pace).

Easing is a fixed set of words: Linear, Ease, EaseIn, EaseOut, EaseInOut. A typo is a compile error that lists the accepted words.

Repeat takes Infinite — the usual choice, because an animation exists for motion that runs until the work ends — or a whole number for a fixed number of passes (Repeat = 3).

Duration is written as a time string: "1.5s" or "600ms". A bare number is refused, because it would be ambiguous between seconds and milliseconds.

Describing the keyframes — stop blocks#

Each stop says where in the run it applies and what is true there. Write them in any order — they run from 0% to 100% regardless:

animation Pulse {
  Duration = "1.4s";
  Easing = EaseInOut;
  Repeat = Infinite;
  50%  { Opacity = 0.45; }
  0%   { Opacity = 1; }
  100% { Opacity = 1; }
}

from and to are accepted as aliases for 0% and 100%, so a keyframe set copied out of a stylesheet reads the same here:

animation SlideIn {
  Duration = "200ms";
  Easing = EaseOut;
  from { TranslateX = "-100%"; }
  to   { TranslateX = "0"; }
}

A stop holds style props only — a flat list. Interaction states (Hover, Focus) and responsive overrides belong in a component's variants block; they have no meaning partway through an animation.

Using theme tokens in a stop#

Because stops use the ordinary style props, they can reference theme tokens. Motion then re-themes with everything else — switching theme or color mode changes the animation with no code change:

theme T { Colors { Surface = "#FFFFFF"; Accent = "#0077B6"; } }

animation Flash {
  Duration = "900ms";
  Easing = EaseOut;
  0%   { Bg = Colors.Accent; }
  100% { Bg = Colors.Surface; }
}

Putting an animation on an element — the animation prop#

Reference the animation by name with the animation style prop — either as an inline argument or in a variants block:

component Status() {
  render { Text("Reconnecting…", animation: Pulse); }
}

The name is checked when you compile. Referring to an animation that does not exist is an error naming the ones that do — because the alternative failure has no symptom at all: the element renders perfectly and simply never moves.

Staggering: animationDelay#

Two elements running one animation are in lockstep — which is right for a pair of skeleton rows and wrong for a chase: a row of bulbs lighting in sequence, a wave across a bar chart, a spinner made of dots. That is one animation with a per-element offset, and the offset is the animationDelay style prop:

animation Bulb {
  Duration = "1.2s";
  Easing = EaseInOut;
  Repeat = Infinite;
  0%   { Opacity = 0.25; }
  50%  { Opacity = 1; }
  100% { Opacity = 0.25; }
}

component Marquee() {
  render {
    Row(gap: 2) {
      foreach (var i in Enumerable.Range(0, 6)) {
        Box(w: 12, h: 12, rounded: 99, bg: "#F5C518", animation: Bulb, animationDelay: (i * 150) + "ms");
      }
    }
  }
}

Six bulbs, one declaration. Without the delay this is six animation declarations that differ only in where their keyframes sit — which is what it used to cost.

The value is a time string with its unit: "200ms" or "0.2s". A bare number is refused for the same reason Duration refuses one — it is ambiguous between seconds and milliseconds, and CSS reads an unsuffixed number as neither, so the declaration would be silently dropped. Any expression in scope may build it ((i * 150) + "ms" above), so the offset can come from a loop index, a parameter or state.

A negative delay starts the animation already partway through, which is how you get a chase that is fully running on the first frame rather than filling in over the first cycle: animationDelay: (i * -150) + "ms".

When not to reach for one#

If the motion has a destination — a color that settles, a panel that finishes opening, a button that grows on hover — use a transition instead. It is simpler, it interrupts and reverses cleanly when the state changes again, and it is what the state-change case is for. Reach for an animation when there is nothing to settle on.

See also#

  • theme tokens — the design tokens a keyframe stop can read.
  • style props — the style-prop vocabulary a stop's body is written in.
  • componentvariants, and where a use site lives.

Related

theme tokens

A `theme` block names your app's design tokens — colors, spacing, radii, and more — as reusable values. A token can…

style props

Inside a `variants` block, each `Name = value` is a style prop from a fixed vocabulary the renderer maps to CSS — paint…

component

The one archetype for all UI: a bounded reactive unit — typed props, reactive members (fields, `live`…