# Animation

_Box Kite 2.2.6 · a markdown copy of https://www.box-kite.dev/animation/_

Keyframes, presets and transitions as props — including the durations that stop on their own when the reader asked for less motion.

## Four presets, no registration

`animation` takes one of four names — `spin`, `pulse`, `bounce` and `ping`. Their `@keyframes` come with the engine and are written into the stylesheet the first time something asks for one, so an unused preset costs nothing at all.

**Presets**

```jsx
<Flex gap={8} ai="center" flexWrap="wrap">
  <Icon size={7} color="sky-500" animation="spin">
    <LoaderCircle />
  </Icon>
  <Box
    width={10}
    height={10}
    borderRadius={2}
    bgColor="violet-500"
    animation="pulse"
  />
  <Box
    width={10}
    height={10}
    borderRadius={5}
    bgColor="emerald-500"
    animation="bounce"
  />
  <Box
    width={10}
    height={10}
    borderRadius={5}
    bgColor="rose-500"
    animation="ping"
  />
</Flex>
```

## A preset already knows about reduced motion

Every preset's duration is a multiple of `--transitionTime`, the variable the base stylesheet zeroes under `prefers-reduced-motion` — so the four above stop on their own, with no opt-in and no media query of yours. The moment you name a duration in milliseconds you have left that default behind, which is the honest trade: the number you wrote wins, and saying when to stop is now your job.

**Opting out by name**

```jsx
<Box
  animationName="docs-slide-in"
  animationDuration={600}
  animationTimingFunction="ease-out"
  motionReduce={{ animationName: 'none' }}
/>
```

## Box.keyframes(): the steps are Box props

A sequence is declared where component styles are, and its stops hold props rather than CSS — the same ÷4 spacing scale, the same colour tokens, the same composed longhands. Register it once at module scope; the name is what `animationName` refers to.

**A sequence of your own**

```jsx
Box.keyframes({
  'slide-in': {
    from: { opacity: 0, translateY: 3 },
    to: { opacity: 1, translateY: 0 },
  },
});

<Box animationName="slide-in" animationDuration={450} animationTimingFunction="ease-out" />
```

## One prop per animation property

`animationName`, `animationDuration`, `animationDelay`, `animationIterationCount`, `animationDirection`, `animationFillMode`, `animationPlayState` and `animationTimingFunction` — declared after `animation`, so a longhand overrides whatever a preset chose. Times are milliseconds, like every other time in this library. The stagger above is three Boxes and one `animationDelay`; each distinct value is one shared class, so a hundred staggered rows generate a hundred one-line rules and nothing else.

**animationPlayState**

```jsx
<Flex
  gap={4}
  ai="center"
  p={4}
  borderRadius={2}
  theme={{ dark: { bgColor: 'slate-900' }, light: { bgColor: 'slate-50' } }}
  className="docs-marquee"
>
  <Icon
    size={6}
    color="amber-500"
    animation="spin"
    hoverGroup={{ 'docs-marquee': { animationPlayState: 'paused' } }}
  >
    <LoaderCircle />
  </Icon>
  <Box fontSize={14}>Hover to pause</Box>
</Flex>
```

## Transitions: what changes, and how long it takes

Every Box transitions `all` its properties over `--transitionTime` already, which is why a `hover` colour fades without being asked. `transition` narrows that to a group — `colors`, `opacity`, `shadow`, `transform`, `size`, `filter` — and `transitionDuration`, `transitionDelay` and `transitionTimingFunction` say the rest. An easing can be a keyword or a curve: `cubic-bezier()`, `steps()` and `linear()` are values, and a typo in one of them emits no rule rather than a broken declaration.

**Transition groups**

```jsx
<Flex gap={4} flexWrap="wrap">
  <Box
    px={5}
    py={3}
    borderRadius={2}
    fontSize={14}
    cursor="pointer"
    bgColor="slate-200"
    transition="colors"
    transitionDuration={300}
    hover={{ bgColor: 'indigo-500', color: 'white' }}
    theme={{ dark: { bgColor: 'slate-800', color: 'slate-200' } }}
  >
    colours only
  </Box>
  <Box
    px={5}
    py={3}
    borderRadius={2}
    fontSize={14}
    cursor="pointer"
    bgColor="slate-200"
    transition="transform"
    transitionDuration={300}
    transitionTimingFunction="cubic-bezier(0.34, 1.56, 0.64, 1)"
    hover={{ scale: 1.08 }}
    theme={{ dark: { bgColor: 'slate-800', color: 'slate-200' } }}
  >
    transform, overshooting
  </Box>
</Flex>
```

## Springs, sampled into a curve

A spring is physics, and CSS cannot do physics — but it can follow a curve, so the four presets are a damped oscillator sampled once into a `linear()` curve, which is a value like any other. `spring`, `spring-gentle`, `spring-bouncy` and `spring-snappy` are values on `transitionTimingFunction` and `animationTimingFunction`; the same four names are values on `transitionDuration` and `animationDuration`, because a spring is a curve _and_ the time it takes to settle. Name both and the physics is what the numbers say.

**The four curves**

```jsx
// Both halves of a spring come from the same name.
<Box transition="transform" transitionTimingFunction="spring-bouncy" transitionDuration="spring-bouncy" hover={{ scale: 1.1 }} />
```

**Same distance, four springs**

```jsx
<Box
  width={8}
  height={8}
  bgImage="gradient-primary"
  transition="transform"
  transitionTimingFunction="spring-snappy"
  transitionDuration="spring-snappy"
  translateX={sprung ? 40 : 0}
/>
```

## What a sampled spring is not

The curve is fixed once it is a string, which is the honest limit: a real spring carries its velocity into whatever interrupts it, and this one restarts. Reverse a transition halfway and the shape plays back rather than continuing from where it was — good enough for a hover, a panel or a toggle, not for a drag. That is `framer-motion`'s job, and it composes with Box styling perfectly well. The other limit is browser support: `linear()` is missing in about one browser in eight, so every curve this library writes carries an `ease-out` declaration underneath it — the older browser keeps that one, and the animation still happens.

**A spring of your own**

```jsx
// Stiffness, damping, mass and an initial velocity — sampled once, at module scope.
const wobble = Box.spring({ stiffness: 120, damping: 8 });

<Box transition="transform" transitionTimingFunction={wobble.easing} transitionDuration={wobble.duration} hover={{ translateY: -2 }} />
```

## The transform props compose

`translateX`, `translateY`, `rotate` and `scale` are the CSS longhands rather than one `transform` declaration, so setting several of them means several of them happen. The two translate axes used to write the same property and one silently won; each now sets its own custom property and both compose into one `translate` — which still transitions, because `var()` is substituted before the browser compares the two states. The exception worth knowing: `flip` and `scale` both write `scale`, so use one or the other.

**Two axes and a rotation**

```jsx
<Flex gap={6} p={6} flexWrap="wrap">
  <Box
    width={16}
    height={16}
    borderRadius={2}
    bgColor="sky-500"
    cursor="pointer"
    hover={{ translateX: 3, translateY: -3, rotate: 45 }}
  />
  <Box
    width={16}
    height={16}
    borderRadius={2}
    bgColor="violet-500"
    cursor="pointer"
    hover={{ scale: 1.25, rotate: -45 }}
  />
</Flex>
```

## An entrance is a prop, not a lifecycle

`startingStyle` holds the values a property starts from the first time the element is styled — which, for something React has just mounted, is the moment it appears. It nests the way a breakpoint does and takes plain props, and since every Box already transitions, that is the entire entrance: no state, no effect, no library, nothing to unmount. It compiles to `@starting-style`, one rule shared by every element that starts from the same place, and a browser that has never heard of the at-rule drops that one rule and shows the element finished.

**startingStyle**

```jsx
{shown && (
  <Box
    width={16}
    height={16}
    bgImage="gradient-primary"
    startingStyle={{ opacity: 0, translateY: 2, scale: 0.96 }}
    transitionDuration={280}
  />
)}
```

## Both directions, without unmounting

An entrance is easy because the element is new; an exit is hard because React removes the node the instant it stops rendering it. The platform's own answer is `transitionBehavior="allow-discrete"`: it lets `display` transition, flipping to `none` at the _end_ rather than the start, so an element that is hidden rather than unmounted animates out as well as in — and `startingStyle` applies again every time it comes back from `display: none`. When the node really does have to leave the tree, holding it long enough to animate is a React problem rather than a CSS one, and `<Presence>` below is the answer to that half.

**transitionBehavior**

```jsx
<Box
  display={open ? 'block' : 'none'}
  opacity={open ? 1 : 0}
  transitionBehavior="allow-discrete"
  startingStyle={{ opacity: 0, translateY: -2 }}
  transitionDuration={260}
/>
```

## Unmounting, but not yet

Hiding is not always an option: a list row, a toast, a route. `<Presence>` keeps rendering its child with `present: false` until the child's own CSS says the transition is over, and only then lets React remove it — so the exit is written as ordinary props on ordinary state. It reads the element's computed `transition-duration` rather than listening for `transitionend`, which fires once per property with no way to know how many are coming; the useful consequence is that a reader on `prefers-reduced-motion` measures zero and the node leaves in the same commit, with nothing to configure. `Tooltip`, the `Dropdown` popup and the DataGrid's column menu are all built on it, so every layer in the library already animates both ways.

**Presence**

```jsx
<Presence present={shown}>
  {({ present, ref, props }) => (
    <Box
      ref={ref}
      props={props}
      opacity={present ? 1 : 0}
      translateY={present ? 0 : -2}
      startingStyle={{ opacity: 0, translateY: -2 }}
      transitionDuration={320}
    />
  )}
</Presence>
```

## Animating to a height nobody measured

`height: auto` has never been animatable, which is why every accordion on the web measures its own content in JavaScript. `interpolateSize="allow-keywords"` opts a subtree into interpolating the size keywords — `auto`, `min-content`, `fit-content` — and it inherits, so it belongs on the container and every size inside it becomes animatable at once. Chromium-only for now, and the degradation is the behaviour you have today: the panel snaps open.

**interpolateSize**

```jsx
<Box interpolateSize="allow-keywords">
  <Box height={expanded ? 'auto' : 0} overflow="hidden" transition="size" transitionDuration={300}>
    <Box p={4}>Content nobody had to measure.</Box>
  </Box>
</Box>
```

## A sequence can animate anything a prop can set

Because a stop is Box props, a sequence is not limited to the four properties an animation library would give you. This bar grows by animating `width` from `0` to `fit` — the size keywords work in a keyframe like they do anywhere else.

**Animating width**

```jsx
Box.keyframes({ draw: { from: { width: 0 }, to: { width: 'fit' } } });

<Box height={2} borderRadius={1} bgImage="gradient-primary" animationName="draw" animationDuration={900} />
```

## Progress that comes from a scroll position

`animationTimeline` takes the animation's progress off a scroll position instead of a clock. `scroll()` is the nearest scrolling ancestor's progress and `view()` is this element's own pass across it — so a reading-progress bar and a reveal-on-scroll are both CSS. No listener, no `requestAnimationFrame`, no `IntersectionObserver`, no state: the browser runs them on the compositor. Scroll the panel below.

**scroll() and view()**

```jsx
Box.keyframes({
  progress: { from: { scale: 0 }, to: { scale: 1 } },
  reveal: { from: { opacity: 0, translateY: 4 }, to: { opacity: 1, translateY: 0 } },
});

<Box height={40} overflow="auto">
  <Box position="sticky" top={0} height={1} bgColor="sky-500" css={{ transformOrigin: 'left' }}
    animationName="progress" animationTimeline="scroll()" animationFillMode="both"
    motionReduce={{ animation: 'none' }} />

  <Box animationName="reveal" animationTimeline="view()" animationRange="entry 0% entry 60%"
    animationFillMode="both" motionReduce={{ animation: 'none' }}>…</Box>
</Box>
```

## Naming a timeline, for when the scroller is somewhere else

`scroll()` only reaches a scroller this element is _inside_. When it is not — a progress bar above the article it tracks — the scroller declares a name with `scrollTimeline` (or the subject with `viewTimeline`), the animated element names it on `animationTimeline`, and a common ancestor opens it up with `timelineScope`, because a timeline is otherwise visible only to the declaring element's descendants. The `--` is optional and added for you; the axis is logical, so `block` mirrors with the writing mode.

**A named timeline**

```jsx
<Box timelineScope="page">
  <Box height={1} bgColor="sky-500" css={{ transformOrigin: 'left' }}
    animationName="progress" animationTimeline="page" animationFillMode="both" />

  <Box height="fit-screen" overflow="auto" scrollTimeline="page block">
    …
  </Box>
</Box>
```

## What a scroll-driven animation does not do for you

Two of these matter more than the props. **The CSS `animation` shorthand resets `animation-timeline`** — the registry declares the timeline after it, so writing both props is safe in either order, but an `animation` written inside `css` sorts last and will undo it. And **a scroll-driven animation has no duration**, so `--transitionTime` cannot zero it: it is the one kind of motion here that does not stop itself when the reader asked for less, which is why every demo above carries `motionReduce={{ animation: 'none' }}`. Where the browser has none — Safari below 26, Firefox behind a flag — the _declaration_ is dropped rather than the animation, so it plays once on the document timeline instead; end the sequence where the element belongs and add `animationFillMode="both"`, and the degradation is "already arrived" rather than a loop.

## A change the browser animates between

A view transition screenshots the page, runs your update, screenshots again and animates between the two — so a change that was a jump becomes a transition with nothing animated by hand. `Box.viewTransition(update)` is the call: feature-detected, and it hands back the same `ready`/`finished`/`updateCallbackDone` promises and a `skip()` whether or not a transition ran, so there is one code path rather than two. Give an element a `viewTransitionName` and it animates _from where it was to where it is_ instead of being cross-faded along with everything else. This site's own theme toggle is one: `<Box.Theme viewTransition="reveal">`, a circle growing out of the button.

**Box.viewTransition()**

```jsx
// flushSync (from react-dom) is the point: the browser screenshots the page the
// moment the callback returns, and a setState has not rendered by then.
<Button onClick={() => Box.viewTransition(() => flushSync(() => setEnd((on) => !on)))}>Move it</Button>

<Flex jc={atEnd ? 'flex-end' : 'flex-start'}>
  <Box viewTransitionName="docs-card" p={4} borderRadius={2} bgColor="violet-500" />
</Flex>
```

## One name, one element

A `viewTransitionName` has to be unique in the document while the transition runs — two elements sharing one is how a transition silently does nothing. So the prop is for the handful of names a layout has (`header`, `main`), while a name _per list item_ would be a rule per item that is never freed: write that one as `props={{ style: { viewTransitionName: id } }}`, the same exception an anchor's name and a slider's thumb already take. `viewTransitionClass` is the other direction — one name shared by many, so `::view-transition-group(.card)` styles the set. Reduced motion **skips** the transition and still applies the update, a whole-page cross-fade being exactly the motion the preference is about; `{ reducedMotion: 'play' }` is the opt-out.

## On a server, and in a Server Component

A sequence is part of the stylesheet, not of a component's markup, so it travels the way every other rule does: `getStyles()` returns it for static output, and in element mode it rides the base `<style>` element every Box carries — which means a Server Component can animate with no client JavaScript anywhere in the page. The loading bar in this site's DataGrid is exactly that: `animationName` in the grid's component styles, and a sweep that starts before hydration.

## Turning the default off

`transition: all` on every Box is a default, not a law. An engine can be told to narrow it to one group, or to declare nothing at all and leave transitions entirely to the props — useful in an app that owns its own motion system.

**Configuring the base transition**

```jsx
// Before the first render — the base block is written once.
Box.configure({ transition: 'colors' });

// Or nothing at all:
Box.configure({ transition: false });
```

## Presence props

Everything below is this component’s own. All 235 of Box’s style props work on it too, and those are on [/box](https://www.box-kite.dev/box.md) rather than repeated here.

| Prop | Type | Default | What it does |
| --- | --- | --- | --- |
| `present`required | `boolean` | — | Whether the child should be there. The exit starts the moment this goes false. |
| `children`required | `(presence: PresenceHandle) => React.ReactNode` | — | The subtree, told which way it is going. |

## Presence accessibility

- The exit is measured from the element's own `transition-duration`/`animation-duration`, so `prefers-reduced-motion` — which zeroes `--transitionTime` — removes the node in the same commit with no opt-in.
- `data-state` is `'open'` or `'closed'` on the child, which is what a CSS exit selects on. A node on its way out is still in the accessibility tree, so anything hiding it has to say so itself.

Not in the automated sweep yet, and screen-reader results are not published.

---

_Every page: https://www.box-kite.dev/llms.txt · every prop, measured: https://www.box-kite.dev/props.md_

