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:
| animation | the reader asks | what it does |
|---|---|---|
KitMenuGrow · KitMenuGrowSide | which thing opened this? | grows from its trigger's corner (pair with transformOrigin), 160ms |
KitArrive | what just arrived? | drops 6px into place, 420ms — stagger with animationDelay |
KitLeave | where did that go? | slides 16px and fades, 240ms — an exitAnimation |
KitPopIn | did someone arrive? | appears from a little smaller, 240ms |
KitDraw | did it land? | a check DRAWS ITSELF along its stroke, 320ms — on the box around an Icon; Done(label) wears it |
KitSettle | did that mark arrive? | a mark that is not a line (a dot, a tile) settles with a small overshoot, once |
KitNudge | did this change? | a count that moved nudges once — key it (animationKey: count) so it plays on the change, not on load |
KitBreathe | is 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#
- theme tokens — the design tokens a keyframe stop can read.
- style props — the style-prop vocabulary a stop's body is written in.
- component —
variants, and where a use site lives. - animationKey — play an animation again when a value changes; a line that draws itself —
animationKey:, an animation that plays again each time a value changes; and a stroke that draws.