# Motion

> Duration, easing, and animation tokens for purposeful, accessible motion.

Reference: [Motion tokens](https://ticketsolutionsptyltd.github.io/roadie/tokens/motion.md).

Motion is feedback. Every transition tells the user what happened, what's
happening, or where to look. If it doesn't do one of those jobs, cut it.

## Motion principles

Three principles define how Oztix products move. Every animation decision
should trace back to one of them.

### Responsive

Motion reacts to input at once. Like a crew backstage, things happen when
they should.

### Grounded

Elements feel like they have weight. Things land where they belong, with just
enough give to feel real.

### Efficient

Every animation earns its place. If it doesn't orient, direct, or confirm, cut
it.

## Duration scale

Duration scales with distance and size. A toggle switch moves fast, and a
drawer needs more time. Match the duration to the physical scale of the
element. On the docs site, each duration is a bar scaled to the longest, with
its value, its class, and its job.

| Token                 | Value  | Class               | Job                                                                |
| --------------------- | ------ | ------------------- | ------------------------------------------------------------------ |
| `--duration-instant`  | 0ms    | `duration-instant`  | Use for a state change that shouldn't animate                      |
| `--duration-fastest`  | 50ms   | `duration-fastest`  | Use for a change that should feel immediate without jumping        |
| `--duration-fast`     | 100ms  | `duration-fast`     | Quick exits, such as Navigator's labels fading out                 |
| `--duration-normal`   | 150ms  | `duration-normal`   | Focus rings, the press scale, and popups                           |
| `--duration-moderate` | 200ms  | `duration-moderate` | Hover colour and shadow, disclosures, dialogs, and the error shake |
| `--duration-slow`     | 300ms  | `duration-slow`     | Drawers, pop-in entrances, and toasts leaving                      |
| `--duration-slower`   | 400ms  | `duration-slower`   | Toasts entering and restacking                                     |
| `--duration-slowest`  | 600ms  | `duration-slowest`  | One-shot attention cues, such as a nudge or a pop                  |
| `--duration-ambient`  | 1800ms | None                | One loop of a tint pulse or an indeterminate bar                   |
| `--duration-sweep`    | 2400ms | None                | One pass of the shimmer across loading surfaces                    |
| `--stagger-base`      | 30ms   | None                | The delay between items, times each item's index                   |

`duration-instant` to `duration-slowest` are Tailwind classes. The two
ambient durations and `--stagger-base` have no class, so read them with
`var()`, as the loading utilities do.

| Tier      | Steps                                       | Use for                                         |
| --------- | ------------------------------------------- | ----------------------------------------------- |
| Micro     | `duration-fastest` to `duration-normal`     | Focus, press, and popups                        |
| Component | `duration-moderate` and `duration-slow`     | Hover colour, disclosures, dialogs, and drawers |
| Page      | `duration-slower` and `duration-slowest`    | Toasts, and the nudge and pop cues              |
| Ambient   | `--duration-ambient` and `--duration-sweep` | One loop or pass of a loading animation         |

## Easing curves

Easing gives motion its character. The three curves suit any property, and
the two springs are for transforms only. On the docs site, each easing is
drawn as its curve over a dashed line at its end value, with its value, its
class, how far it overshoots, and its job.

| Token                  | Value                                                                                                                                                                                  | Class                | Overshoot | Job                                                                                                 |
| ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------- | --------- | --------------------------------------------------------------------------------------------------- |
| `--ease-standard`      | `cubic-bezier(0.4, 0, 0.2, 1)`                                                                                                                                                         | `ease-standard`      | None      | Colour, opacity, and shadow, and popups entering and leaving                                        |
| `--ease-enter`         | `cubic-bezier(0, 0, 0.2, 1)`                                                                                                                                                           | `ease-enter`         | None      | Elements arriving and form fields changing state. Starts fast, settles gently.                      |
| `--ease-exit`          | `cubic-bezier(0.4, 0, 1, 1)`                                                                                                                                                           | `ease-exit`          | None      | Elements leaving. Starts slow, speeds up out.                                                       |
| `--ease-spring`        | `linear(0, 0.006, 0.025 2.8%, 0.101 6.1%, 0.539 18.9%, 0.721 25.3%, 0.849 31.5%, 0.937 38.1%, 0.968 41.8%, 0.991 45.7%, 1.006 50.1%, 1.015 55%, 1.017 63.9%, 1.001 85.9%, 1)`          | `ease-spring`        | 1.7%      | Small transforms, such as the press scale and the tap pop. Too little overshoot to see as a bounce. |
| `--ease-spring-lively` | `linear(0, 0.012 1.7%, 0.044 3.3%, 0.092 5%, 0.223 8.3%, 0.607 16.7%, 0.744 20%, 0.859 23.3%, 0.949 26.7%, 1.015 30%, 1.073 35%, 1.094 40%, 1.085 46.7%, 1.008 66.7%, 0.991 81.7%, 1)` | `ease-spring-lively` | 9.4%      | Transforms that should visibly bounce, such as toasts                                               |

`--ease-spring` peaks 1.7% past its end, too little to see as a bounce on a
move under 100px, so it reads as a soft settle. `--ease-spring-lively`
overshoots by about 9%, which shows as a bounce, so keep it for surfaces that
should catch the eye, such as toasts.

## Motion utilities

`animate-*` is a keyframe animation that plays as soon as the class is on the
element. `motion-*` is a transition that plays as an element enters or
leaves, on Base UI's `data-starting-style` and `data-ending-style`. Both
compose with Tailwind's `motion-safe:` and `motion-reduce:` variants, and the
reduced motion reset cuts both short, as Accessibility describes.

### Duration classes

`duration-*` sets `transition-duration` from the scale. Select Play to run
each bar over its duration.

```tsx
function Durations() {
  const [played, setPlayed] = useState(false)
  const durations = [
    'duration-fast',
    'duration-normal',
    'duration-moderate',
    'duration-slow',
    'duration-slowest'
  ]
  return (
    <div className='grid gap-3'>
      <Button emphasis='normal' size='sm' className='justify-self-start' onClick={() => setPlayed(!played)}>
        {played ? 'Reset' : 'Play'}
      </Button>
      {durations.map((duration) => (
        <div key={duration} className='grid grid-cols-[9rem_minmax(0,1fr)] items-center gap-3'>
          <code className='text-sm'>{duration}</code>
          <div className='h-2 rounded-full emphasis-sunken'>
            <div className={`h-full origin-left rounded-full bg-strong intent-brand transition-transform ease-standard ${duration} ${played ? 'scale-x-100' : 'scale-x-0'}`} />
          </div>
        </div>
      ))}
    </div>
  )
}

render(<Durations />)
```

### Easing classes

`ease-*` sets `transition-timing-function` from the curves. Each bar runs over
`duration-slowest`, so the shape of each curve shows. The lively spring runs
past the end of its track.

```tsx
function Easings() {
  const [played, setPlayed] = useState(false)
  const easings = [
    'ease-standard',
    'ease-enter',
    'ease-exit',
    'ease-spring',
    'ease-spring-lively'
  ]
  return (
    <div className='grid gap-3'>
      <Button emphasis='normal' size='sm' className='justify-self-start' onClick={() => setPlayed(!played)}>
        {played ? 'Reset' : 'Play'}
      </Button>
      {easings.map((easing) => (
        <div key={easing} className='grid grid-cols-[9rem_minmax(0,1fr)] items-center gap-3'>
          <code className='text-sm'>{easing}</code>
          <div className='h-2 rounded-full emphasis-sunken'>
            <div className={`h-full origin-left rounded-full bg-strong intent-brand transition-transform duration-slowest ${easing} ${played ? 'scale-x-100' : 'scale-x-0'}`} />
          </div>
        </div>
      ))}
    </div>
  )
}

render(<Easings />)
```

### Entrances

Entrances play once when the element mounts. Select Replay to mount the cards
again.

```tsx
function Entrances() {
  const [run, setRun] = useState(0)
  return (
    <div className='grid gap-4'>
      <Button emphasis='normal' size='sm' className='justify-self-start' onClick={() => setRun(run + 1)}>
        Replay
      </Button>
      <div key={run} className='grid gap-3 sm:grid-cols-3'>
        <Card emphasis='raised' className='animate-fade-in'>
          <Card.Content>
            <Card.Title>Ochre Kite Weekender</Card.Title>
            <Card.Description>animate-fade-in</Card.Description>
          </Card.Content>
        </Card>
        <Card emphasis='raised' className='animate-scale-in'>
          <Card.Content>
            <Card.Title>Lampshade Disco</Card.Title>
            <Card.Description>animate-scale-in</Card.Description>
          </Card.Content>
        </Card>
        <Card emphasis='raised' className='origin-bottom animate-pop-in'>
          <Card.Content>
            <Card.Title>Nectarine Hoedown</Card.Title>
            <Card.Description>animate-pop-in</Card.Description>
          </Card.Content>
        </Card>
      </div>
    </div>
  )
}

render(<Entrances />)
```

| Utility            | What it does                                                          |
| ------------------ | --------------------------------------------------------------------- |
| `animate-fade-in`  | Fades in from transparent                                             |
| `animate-scale-in` | Scales up from 95% as it fades in, on `--ease-spring`                 |
| `animate-pop-in`   | Fades, slides up, and scales in. Pair with `origin-*` to set its edge |

### Attention cues

A cue plays once each time the class goes on, so remount the element, or
remove and add the class, to replay it. Add a ticket to see `animate-pop` on
the badge.

```tsx
function AddToCart() {
  const [count, setCount] = useState(2)
  return (
    <div className='flex flex-wrap items-center gap-3'>
      <Button emphasis='strong' onClick={() => setCount(count + 1)}>
        Add a ticket
      </Button>
      <Badge key={count} intent='accent' emphasis='strong' className='animate-pop'>
        {count} in cart
      </Badge>
    </div>
  )
}

render(<AddToCart />)
```

| Utility           | What it does                                                   |
| ----------------- | -------------------------------------------------------------- |
| `animate-shake`   | Shakes sideways, for a validation error or an invalid input    |
| `animate-nudge`   | Nudges up and back, such as a cart that takes a new item       |
| `animate-pop`     | Pops larger and back, such as a badge whose count changes      |
| `animate-pop-tap` | The same pop, faster and on a spring, for feedback under a tap |

### Loading

Loading animations loop until the content arrives. Skeleton applies
`animate-shimmer`, and Progress applies `animate-indeterminate` when it has no
value.

```tsx
{/* Pulse */}
<div className='h-10 rounded-xl emphasis-subtle animate-pulse-subtle' />
{/* Shimmer */}
<div className='h-10 rounded-xl emphasis-subtle animate-shimmer' />
{/* Indeterminate */}
<div className='relative h-2 overflow-hidden rounded-full emphasis-sunken'>
  <div className='rounded-full bg-strong intent-brand animate-indeterminate' />
</div>
```

| Utility                 | What it does                                                                             |
| ----------------------- | ---------------------------------------------------------------------------------------- |
| `animate-pulse-subtle`  | Pulses the opacity, for a surface waiting on something                                   |
| `animate-shimmer`       | Pulses under a highlight anchored to the viewport, so every placeholder shares one sweep |
| `animate-indeterminate` | Sweeps a bar across a positioned parent that clips its overflow                          |

Tailwind's `animate-spin` is only for a small status icon that stands in for a
pending action, as Toast's loading icon does. Anything larger, or any bar or
surface that waits, uses Progress or a loading utility above.

### Enter and exit transitions

These transitions run on Base UI's starting and ending styles, so the exit
plays before the element unmounts. Open the popover to see `motion-scale`.

```tsx
<Popover>
  <Popover.Trigger render={<Button>Set times</Button>} />
  <Popover.Content>
    <Popover.Header>
      <Popover.Title>Feathered Anchor Sessions</Popover.Title>
      <Popover.Description>Doors 7pm, first act 8pm.</Popover.Description>
    </Popover.Header>
  </Popover.Content>
</Popover>
```

| Utility                  | What it does                                                          | Used by                    |
| ------------------------ | --------------------------------------------------------------------- | -------------------------- |
| `motion-scale`           | Scales from 95% and fades. Pair with `origin-*` to scale from a point | Menu, Popover, and Tooltip |
| `motion-slide`           | Fades and slides up on enter, down on exit                            | Dialog                     |
| `motion-drawer`          | Slides off its anchored edge and tracks a live swipe                  | Drawer                     |
| `motion-toast`           | Stacks, fans out, and tracks a live swipe                             | Toast                      |
| `is-disclosure-animated` | Opens and closes a `<details>` or collapsible panel by height         | Accordion and Collapsible  |

### Deprecated utilities

These go in v3. Swap each for its replacement.

| Utility            | Replacement                      |
| ------------------ | -------------------------------- |
| `motion-fade-in`   | `animate-fade-in`                |
| `motion-scale-in`  | `animate-scale-in`               |
| `motion-pop-in`    | `animate-pop-in`                 |
| `motion-fade-out`  | `motion-scale` or `motion-slide` |
| `motion-scale-out` | `motion-scale` or `motion-slide` |

## Motion patterns

Motion tokens are wired into the other foundations. These are the places they
meet.

### Interactive elements

`is-interactive` animates colour, shadow, outline, and transform from the
motion tokens, so a button needs no transition of its own. Hover and press
the button to see the colour shift and the press scale.

```tsx
<Button emphasis='strong'>Buy tickets</Button>
```

`--interactive-transition` sets each property that animates, with its
duration and its easing token.

| Property           | Duration | Easing          |
| ------------------ | -------- | --------------- |
| `background-color` | 200ms    | `ease-standard` |
| `border-color`     | 200ms    | `ease-standard` |
| `color`            | 200ms    | `ease-standard` |
| `box-shadow`       | 200ms    | `ease-standard` |
| `outline-width`    | 150ms    | `ease-standard` |
| `outline-color`    | 150ms    | `ease-standard` |
| `transform`        | 150ms    | `ease-spring`   |

Hover and focus colours follow the intent cascade, so a button inside
`intent-danger` shifts to danger colours on the same durations and easings.
`is-interactive-field` moves form fields on `--ease-enter` instead. See
[Interactions](https://ticketsolutionsptyltd.github.io/roadie/foundations/interactions.md) for the full breakdown.

### Raised surfaces

`emphasis-raised` lifts and deepens its shadow on hover or keyboard focus
only when it also has `is-interactive`, or `is-interactive-within` with a
focused or hovered target. Their transition animates the change. On its own
it has no transition. Hover or tab to each card to compare. A `Card` with an
`href` takes `is-interactive`.

```tsx
{/* Static */}
<Card emphasis='raised'>
  <Card.Content>
    <Card.Title>Tuxedo Possum Cabaret</Card.Title>
  </Card.Content>
</Card>
{/* Interactive */}
<Card emphasis='raised' href='#raised-surfaces'>
  <Card.Content>
    <Card.Title>Tuxedo Possum Cabaret</Card.Title>
  </Card.Content>
</Card>
```

The shadow moves from `shadow-md` to `shadow-lg` over `duration-moderate` on
`ease-standard`, and the lift rides the transform on `ease-spring`. See
[Elevation](https://ticketsolutionsptyltd.github.io/roadie/foundations/elevation.md) for the shadow scale.

### Staggered lists

Delay each item by `--stagger-base` times its index, so the eye follows the
list in order. `--stagger-base` has no class. Select Replay to mount the
lineup again.

```tsx
function Lineup() {
  const [run, setRun] = useState(0)
  const acts = [
    'Middle Kids',
    'Hiatus Kaiyote',
    'Gang of Youths',
    'Amyl and the Sniffers'
  ]
  return (
    <div className='grid gap-4'>
      <Button emphasis='normal' size='sm' className='justify-self-start' onClick={() => setRun(run + 1)}>
        Replay
      </Button>
      <ol key={run} className='grid gap-2'>
        {acts.map((act, index) => (
          <li
            key={act}
            className='rounded-xl emphasis-raised p-3 animate-pop-in [animation-delay:calc(var(--stagger-base)*var(--index))]'
            style={{ '--index': index }}
          >
            {act}
          </li>
        ))}
      </ol>
    </div>
  )
}

render(<Lineup />)
```

### Page transitions

A route change that moves the page uses the same tokens through the View
Transitions API. The old page leaves over `--duration-moderate` on
`--ease-exit`, and the new one arrives over `--duration-slow` on
`--ease-enter`. See [View transitions](https://ticketsolutionsptyltd.github.io/roadie/foundations/view-transitions.md) for the
recipe.

## Guidelines

- Never block the interface. A user who selects Close on an opening dialog
  should see it close at once. Prefer transitions, which reverse from where
  they are, over keyframe animations, which play to the end.
- Don't make anyone watch a two-second success animation before they can
  select Next. It's delightful once and infuriating the fiftieth time.
- Test every animation with reduced motion on before you ship it, as
  Accessibility describes.
- Don't force parallax or other scroll-driven motion on someone who has asked
  for reduced motion. Put it behind `motion-safe:`.

### Give every animation a job

Motion shows cause and effect. If an animation doesn't orient, direct focus, give feedback, or show a state change, cut it.

**Do**

```tsx
<Button emphasis='strong'>Buy tickets</Button>
```

Scale a button down as it's pressed, to confirm the press landed. Scale a
popup in from its trigger, to show what opened it. Each transition has one
job.

**Don’t**

```tsx
<p className='animate-bounce text-display-ui-5 text-strong'>Ochre Kite Weekender</p>
```

Don't bounce a heading on page load because it looks fun. Motion on static
content rarely says anything.

### Be specific about what moves

Precision prevents side effects. Name each property that transitions.

**Do**

```tsx
transition:
  background-color var(--duration-moderate) var(--ease-standard),
  transform var(--duration-normal) var(--ease-spring);
```

List each property. `is-interactive` does this for interactive elements.

**Don’t**

```tsx
transition: all 150ms ease-out;
```

Don't use `transition-all`. It animates padding, font size, and anything
else that changes, which recalculates layout on every frame.

### Use tokens, not magic numbers

Tokens keep motion consistent as the system grows. Hardcoded values drift.

**Do**

```tsx
transition:
  background-color var(--duration-moderate) var(--ease-standard);
```

Use the duration and easing tokens, so one change updates every component.

**Don’t**

```tsx
transition: background-color 0.2s cubic-bezier(0.4, 0, 0.2, 1);
```

Don't copy `0.2s cubic-bezier(0.4, 0, 0.2, 1)` across 15 files. One typo
and things feel off.

### Springs for transforms, curves for colour

A spring suggests mass and momentum, which only makes sense for something that moves.

**Do**

```tsx
transition:
  transform var(--duration-normal) var(--ease-spring),
  background-color var(--duration-moderate) var(--ease-standard);
```

Use `ease-spring` for small transforms, where it settles softly, and
`ease-spring-lively` when the bounce should show. Use `ease-standard` for
colour and opacity.

**Don’t**

```tsx
transition: opacity var(--duration-moderate) var(--ease-spring-lively);
```

Don't put a spring on opacity or colour. Nothing about a colour change has
momentum, and an overshoot on opacity just flickers.

### Grow from where they are

An element that expands should expand from its anchor.

**Do**

```tsx
<div className='origin-[var(--transform-origin)] motion-scale'>…</div>
```

Scale a popup from its trigger. Base UI sets `--transform-origin`, and
Menu, Popover, Tooltip, Select, and Combobox already scale from it.

**Don’t**

```tsx
<div className='origin-center motion-scale'>…</div>
```

Don't scale a popup from its centre when its trigger is in a corner. It
breaks the link between the two.

### Stagger, don't flood

Choreography creates hierarchy. Stagger related elements to guide the eye.

**Do**

```tsx
<li
  className='animate-pop-in [animation-delay:calc(var(--stagger-base)*var(--index))]'
  style={{ '--index': index }}
>
```

Delay each item in a list by `--stagger-base` times its index, so the
eye follows the sequence.

**Don’t**

Don't animate all 12 cards in a grid at once. The result is a flash, not a
reveal.

## Accessibility

Motion makes some people feel sick. For them it's a health issue, not a
preference. When someone sets `prefers-reduced-motion: reduce`, Roadie's
global reset sets these on every element and its `::before` and `::after`:

- `animation-duration` and `transition-duration` to `0.01ms`, not `0ms`, so
  `animationend` and `transitionend` still fire for code that waits on them.
- `animation-iteration-count` to `1`, so a loop plays once and stops.
- `animation-delay` and `transition-delay` to `0s`, so a staggered list
  appears at once instead of waiting out its delays.
- `scroll-behavior` to `auto`, so a smooth scroll jumps.

Two loading utilities hold a still state instead. `animate-shimmer` drops its
gradient and keeps the tint, and `animate-indeterminate` holds a full-width
bar at a soft tint. `animate-pulse-subtle` plays one short pass and stays at
full opacity.

The reset only reaches CSS animations and transitions on elements. Animation
run from JavaScript, such as the Web Animations API or a smooth `scrollTo()`,
and the `::view-transition-*` pseudo-elements have to check
`matchMedia('(prefers-reduced-motion: reduce)')` themselves. Roadie's
components do: Pane scrolls to the top without smoothing, Calendar skips its
swipe animation, and Carousel stops autoplay and jumps between slides. A
delay timed in JavaScript, such as the `delay` on Tooltip or a hover Popover,
isn't motion and still waits.

Tailwind's `motion-safe:` and `motion-reduce:` variants swap a motion for a
still alternative, rather than cutting it short.

To test, open the Rendering tab in Chrome DevTools and set "Emulate CSS media
feature prefers-reduced-motion" to "reduce". On macOS, turn on Reduce motion
in System Settings, under Accessibility and then Display.

## Quick reference

How the motion tokens map to components. See
[Interactions](https://ticketsolutionsptyltd.github.io/roadie/foundations/interactions.md) for the full `is-interactive` and
`is-interactive-field` breakdown.

| What                      | Duration                                           | Easing                                        | How                                     |
| ------------------------- | -------------------------------------------------- | --------------------------------------------- | --------------------------------------- |
| Hover colour and shadow   | `duration-moderate`                                | `ease-standard`                               | `is-interactive`                        |
| Press scale               | `duration-normal`                                  | `ease-spring`                                 | `is-interactive`, at `scale(0.99)`      |
| Field hover and focus     | `duration-moderate`                                | `ease-enter`                                  | `is-interactive-field`                  |
| Focus ring                | `duration-normal`                                  | `ease-standard`, or `ease-enter` on a field   | Outline width and colour                |
| Raised surface lift       | `duration-moderate` shadow, `duration-normal` lift | `ease-standard` shadow, `ease-spring` lift    | `emphasis-raised` with `is-interactive` |
| Caret rotation            | `duration-moderate`                                | Tailwind's default, the `ease-standard` curve | Select and Combobox triggers            |
| Popup enter and exit      | `duration-normal`                                  | `ease-standard`                               | `motion-scale`                          |
| Dialog enter and exit     | `duration-moderate`                                | `ease-standard`                               | `motion-slide`                          |
| Drawer enter and exit     | `duration-slow`                                    | `ease-enter`                                  | `motion-drawer`                         |
| Toast enter and restack   | `duration-slower`                                  | `ease-spring-lively` on the transform         | `motion-toast`                          |
| Toast exit                | `duration-slow`                                    | `ease-exit`                                   | `motion-toast`                          |
| Accordion and Collapsible | `duration-moderate`                                | `ease-enter`                                  | `is-disclosure-animated`, on height     |

Docs updated 20 Oct 2026.
