Osy#betaRun it on Osyrin: osyrin.com

Reference / UI

animation — keyframe motion, on entry, on exit, in a loop, or as it scrolls into view

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

An `animation` block declares reusable keyframe motion — an entrance, a shimmer, a pulse, a flash that decays. Its stops are written with the ordinary style props, so they are checked at compile time and can read theme tokens, and the timing lives on the declaration, so a use site is a single `animation: Name` reference. The same declaration can play as an element LEAVES (`exitAnimation:`) or as it SCROLLS INTO VIEW (`animationTimeline:`), and someone who has asked for less motion gets its fade without its movement.

stable11 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".

An entrance — it plays once, as the element appears#

An animation with no Repeat runs once, and it starts when its element is inserted — so an animation on an element IS its entrance. It holds its first stop through any animationDelay, so a staggered row of cards starts invisible and rises in turn rather than showing each card first and then snapping it back to play:

animation Rise {
  Duration = "420ms";
  Easing = EaseOut;
  from { Opacity = 0; TranslateY = "12px"; }
  to   { Opacity = 1; TranslateY = "0"; }
}

component Cards() {
  render {
    Row(gap: 3) {
      foreach (var i in Enumerable.Range(0, 4)) {
        Box(w: 120, h: 80, rounded: 8, bg: "#E8EEF6", animation: Rise, animationDelay: (i * 70) + "ms");
      }
    }
  }
}

An exit — exitAnimation#

exitAnimation names an animation that plays as the element leaves: when it is the element an if branch or a foreach row built, and that branch goes false or that row's item goes away. The element stays until the animation ends and is removed then; while it plays it takes no click and no focus, so a card already on its way out cannot be deleted twice.

animation FadeIn  { Duration = "200ms"; Easing = EaseOut; from { Opacity = 0; } to { Opacity = 1; } }
animation SlideOut { Duration = "220ms"; Easing = EaseIn; from { Opacity = 1; } to { Opacity = 0; TranslateX = "24px"; } }

component Toast() {
  bool shown = true;
  action Dismiss() { shown = false; }
  render {
    if (shown) {
      Row(gap: 2, p: 3, rounded: 8, bg: "#F2F4F7", animation: FadeIn, exitAnimation: SlideOut) {
        Text("Saved");
        Button("Dismiss", onPress: Dismiss);
      }
    }
  }
}

It works in a variants block too (ExitAnimation = SlideOut;), which is how a component gives every instance its exit. The exit plays for the element the branch or row BUILT at its top — something nested inside it leaves with it.

An exit has to end: naming an animation with Repeat = Infinite is a compile error, because there would be no moment at which the element may go.

As it scrolls into view — animationTimeline#

animationTimeline: AnimationTimeline.View plays the element's animation as it scrolls into view instead of when it is inserted — the "reveal on scroll" of a landing page or a product grid. It is the browser's own view timeline: the animation runs from the moment the element's top edge enters the viewport until a third of it is in view, and plays backwards if it is scrolled out again.

animation Reveal {
  Duration = "1s";
  from { Opacity = 0; TranslateY = "24px"; }
  to   { Opacity = 1; TranslateY = "0"; }
}

component Feature() {
  variants { base { Animation = Reveal; AnimationTimeline = AnimationTimeline.View; } }
  render { Stack(gap: 2) { Text("Built for the long haul"); } }
}

A browser that does not have view timelines ignores the setting and plays the animation as an ordinary entrance, so the content is never left hidden. AnimationTimeline.Auto, the default, is the ordinary clock.

Reduced motion — handled for you#

Someone whose system asks for reduced motion gets every animation with its movement taken out and its fade kept: an entrance that rose into place simply fades in, an exit that slid out fades out, a scroll reveal fades, and a shimmer still shimmers. The platform re-declares each animation that moves anything for that setting, so there is nothing to write at a use site. Colour, opacity and shadow are kept; position, rotation and scale are dropped — and so is a stroke's travel along itself (StrokeDashoffset), so a check that draws itself is simply there ([[ui-animation-key#draw]]).

A drawing on a Canvas is not an animation and is not stripped for you: its body asks Theme.ReducedMotion() and draws a still frame ([[ui-canvas#themed]]).

The kit's motion vocabulary — use these before writing your own#

Osysharp.Ui ships the motions an app's controls need, each answering one question a reader has, and its own controls use them for exactly that:

animationthe reader askswhat it does
KitMenuGrow · KitMenuGrowSidewhich thing opened this?grows from its trigger's corner (pair with transformOrigin), 160ms
KitArrivewhat just arrived?drops 6px into place, 420ms — stagger with animationDelay
KitLeavewhere did that go?slides 16px and fades, 240ms — an exitAnimation
KitPopIndid someone arrive?appears from a little smaller, 240ms
KitDrawdid it land?a check DRAWS ITSELF along its stroke, 320ms — on the box around an Icon; Done(label) wears it
KitSettledid that mark arrive?a mark that is not a line (a dot, a tile) settles with a small overshoot, once
KitNudgedid this change?a count that moved nudges once — key it (animationKey: count) so it plays on the change, not on load
KitBreatheis this live?a ring expanding to nothing, every 2.4s — what Lamp(live: true) wears
[Route("/")]
[AllowAnonymous]
component Queue() {
  List<string> rows = ["Card payments failing for EU customers"];
  action Add() { rows.Insert(0, "Invoice PDF shows the wrong VAT number"); }
  render {
    Stack(gap: 2, p: 6) {
      Row(gap: 2, align: Align.Center) { Lamp(live: true); Text("Unassigned"); }
      Button("Simulate a new ticket", onPress: Add);
      foreach (var r in rows) { Text(r, animation: KitArrive); }
    }
  }
}

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#

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`…