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

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

<!-- id: ui-animation · area: ui · stability: stable · html: https://osysharp.com/reference/ui/animation/ -->

## Summary        {#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*.

```osy title="a skeleton shimmer" test app=ui-animation
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      {#signature}
```osy syntax
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    {#description}

### Where an `animation` is declared — top level, not in a theme   {#placement}
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?   {#timing}
`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:

```osy title="one reference — the timing comes with it" test app=ui-animation
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   {#stops}
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:

```osy title="a three-stop pulse; declared out of order on purpose" test app=ui-animation
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:

```osy title="from / to" test app=ui-animation
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    {#tokens}
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:

```osy title="a stop reading theme tokens" test app=ui-animation
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   {#applying}
Reference the animation by name with the `animation` style prop — either as an inline argument or in a `variants`
block:

```osy title="inline, on any element" test app=ui-animation
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`    {#delay}
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:

```osy title="one animation, N elements, a chase" test app=ui-animation
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    {#entrance}
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:

```osy title="cards that rise in, one after another" test app=ui-animation
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`    {#exit}
`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.

```osy title="a toast that fades in and slides out" test app=ui-animation
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`    {#scroll}
`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.

```osy title="a section that rises as it is scrolled to" test app=ui-animation
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    {#reduced-motion}
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    {#kit-vocabulary}
`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 |

```osy title="a new row arriving, and a live lamp beside a queue" test app=ui-animation-kit
[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    {#when-not}
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   {#see-also}
- [theme tokens](https://osysharp.com/reference/ui/theming/) — the design tokens a keyframe stop can read.
- [style props](https://osysharp.com/reference/ui/styling/) — the style-prop vocabulary a stop's body is written in.
- [component](https://osysharp.com/reference/ui/component/) — `variants`, and where a use site lives.
- [animationKey — play an animation again when a value changes; a line that draws itself](https://osysharp.com/reference/ui/animation-key/) — `animationKey:`, an animation that plays again each time a value changes; and a stroke that draws.
