---
title: Motion and depth
description: Every transition, animation and perspective in the stylesheet, what each is for, and where it is used.
source: https://adamjurek.com/components/motion
---

## Home


There are about a dozen moving things on this site. This is all of them, why
each moves at the speed it does, and which file to open.

The rule underneath the whole list: **motion answers a question the reader just
asked.** A panel opening was asked for by a press. A card lifting answers "is
this clickable". Nothing here moves to be noticed.

<!-- ::start:spacer size="6" rule="true" -->
<!-- ::end:spacer -->

## The one easing token

```css
--ease-out: cubic-bezier(0.23, 1, 0.32, 1);
```

One curve, used for everything that travels. It starts fast and settles slowly,
which is how a thing that was pushed behaves, and the long tail is what makes
an interface feel unhurried without actually being slow.

Plain `ease` is used for colour changes, because a colour does not have
momentum and giving it some reads as a delay.


## Get Started


## Install

```shell
pnpm add @sushindustries/atoms
```

There is no registry entry for this - it is not a component, so there is no
TanStack or shadcn command and nothing to add beyond the package itself.

## Use it

```ts
import "@sushindustries/atoms/atoms.css";
```

Once, at the app root. Every motion rule described on this page - the
easing token, the reveal-on-scroll fade, the perspective stage, the glass
blur - arrives with that one import. Nothing here is opt-in per component.

```tsx
<div data-reveal="out">
	<p>Fades and rises into place as it enters the viewport.</p>
</div>
```

## What you should see

An element with `data-reveal="out"` starts invisible and shifted 18px down.
Flip the attribute to `"in"` - which `Reveal` and `Section` do automatically
on scroll - and it fades and rises over 700ms with `--ease-out`. Under
`prefers-reduced-motion: reduce` it appears immediately in its final
position instead; nothing here holds it hidden.

## If nothing happens

An element stuck at `data-reveal="out"` forever, invisible, means nothing is
flipping the attribute to `"in"` - `data-reveal` is inert CSS, and the
transition only exists once something sets the attribute. Reach for `Reveal`
or `Section` from `packages/ui` rather than writing the attribute by hand,
unless the intersection logic is genuinely something else's job.


## Guides


## Durations, and why they differ

Duration is a function of distance and of how often the control is used, not of
taste.

| Where | Duration | Why that number |
| --- | --- | --- |
| Colour on hover | 160ms | Under ~200ms a colour change reads as instant-but-soft. Longer and the interface feels like it is thinking |
| Card lift | 260ms | It moves 2px. Short travel, but it should feel like weight rather than a twitch |
| Nav chevron | 220ms | Rotates 180°, so it is travel, not a state flip |
| Nav panel in | 220ms | Appears in place. It grows into position rather than arriving from somewhere |
| Mobile drawer | 420ms | Crosses the whole screen. A full-screen surface arriving in 200ms reads as a page change, not as something opening |
| Reveal on scroll | 700ms | Not a response to a press. It is scenery, and scenery that hurries draws attention it did not earn |
| Showcase width | 320ms | The width change *is* the information - it has to be legible |

<!-- ::start:spacer size="6" label="No transition" -->
<!-- ::end:spacer -->

## Where motion is deliberately absent

The device toggle in `Showcase` has no transition at all.

It gets pressed a dozen times while reading one page, and on a control used
that often an animation reads as lag rather than as polish. The state change is
the feedback; anything added on top is a wait.

That is the general test: **how many times will this be seen in a session?**
Once or twice, animate it. Twenty times, do not.

## Depth: `perspective`

```css
.logo-stage {
	perspective: 1100px;
}

.logo-spin {
	transform-style: preserve-3d;
	will-change: transform;
}
```

Two properties, and which element carries which is the whole trick.

**`perspective` goes on the parent, not on the thing that turns.** On the
parent it establishes one vanishing point for the whole stage, so a mark
rotating inside it turns about the centre of the space it occupies - like an
object in a box. Put `perspective()` in the child's own transform instead and
each element gets its own vanishing point at its own centre, and a row of them
all splay outward.

**1100px is the viewing distance.** Smaller is a wider lens: more dramatic,
more distortion, and at very small values the near edge swings past the camera
and inverts. Larger flattens toward orthographic. Roughly the width of the
element is a sane starting point, and this stage is ~460px, so 1100px is a
gentle lens.

**`transform-style: preserve-3d`** keeps children in the same 3D space as their
parent rather than flattening them into a picture. Without it a nested
transform is composited to a plane first and the depth disappears.

**`will-change: transform`** promotes the element to its own compositor layer
so a per-frame transform does not repaint. It is deliberately on one element:
`will-change` costs memory per layer, and applying it broadly is how a page
gets slower by being told to go faster.

> [!CAUTION] Perspective is not free composition
> A `perspective` ancestor, like `backdrop-filter` and `transform`, becomes the
> containing block for `position: fixed` descendants. A fixed overlay inside a
> perspective stage will measure itself against the stage. That exact bug
> - via `backdrop-filter` on the header - made the mobile drawer sixty pixels
> tall.

## Glass, and the blur budget

```css
--glass: color-mix(in srgb, var(--nori-700) 62%, transparent);
--glass-edge: color-mix(in srgb, var(--rice) 9%, transparent);
--glass-blur: blur(18px) saturate(130%);
```

Frosted surfaces are three things: a translucent fill, a lit top edge, and a
blur. The **edge does most of the work** - a gradient from a faint light at the
top fading out by halfway is what separates "sheet of glass" from "translucent
box", and it costs one gradient.

**One blur per surface.** `backdrop-filter` makes its element a backdrop root
and forces a GPU readback every frame. They nest and multiply: eighteen blurred
icon tiles inside a blurred panel inside a blurred header crashed the renderer
outright, error code 5.

| Surface | Fill | Edge | Blur |
| --- | --- | --- | --- |
| Header | yes | yes | yes - it is over the page |
| Nav panel | yes | yes | yes - it is over the page |
| Mobile drawer | opaque | no | **no** - it sits over a 62% scrim, so the blur composited something already hidden |
| Card | yes | yes | **no** - it is a surface *on* the page, with nothing behind it worth blurring |
| Icon tile | yes | yes | **no** - a 34px tile gains nothing, and there are eighteen |

## Reduced motion

Every animated thing here checks `prefers-reduced-motion: reduce` and stops.

The important half is what it degrades *to*. A `Reveal` that respects the
preference shows its children immediately; leaving them hidden turns an
accessibility setting into a blank page. `useScrollTurn` fires once at the
current position rather than never, so whatever it drives is left in a sensible
pose rather than at zero - a model parked at rotation zero may be showing you
its back.

## Where each one lives

| Motion | Defined in | Used by |
| --- | --- | --- |
| `[data-reveal]` fade and rise | `atoms.css` | `Reveal`, `Section` |
| `.logo-stage` perspective | `atoms.css` | `ScrollSpin`, the home page hero |
| Scroll-driven rotation | `use-scroll-turn.ts` | `ScrollSpin` (CSS), `logo-model.tsx` (three.js) |
| `nav-panel-in` | `atoms.css` | `NavBar` desktop panels |
| `nav-sheet-in`, `nav-scrim-in` | `atoms.css` | `NavBar` mobile drawer |
| Burger bars to cross | `atoms.css` | `NavBar` toggle |
| Card lift | `atoms.css` | `Card`, archive cards |
| Frame width | `atoms.css` | `Showcase` viewport switch |


## API


## Tokens

| Token | Value | Used for |
| --- | --- | --- |
| `--ease-out` | `cubic-bezier(0.23, 1, 0.32, 1)` | Anything that travels: opens, lifts, slides. |
| `--glass` | `color-mix(in srgb, var(--nori-700) 62%, transparent)` | A frosted surface's fill. |
| `--glass-edge` | `color-mix(in srgb, var(--rice) 9%, transparent)` | The lit top edge on a glass surface. |
| `--glass-blur` | `blur(18px) saturate(130%)` | `backdrop-filter`, applied once per surface, never nested. |

Plain `ease`, not a custom token, is used for colour transitions - colour
has no momentum, and `--ease-out`'s long tail on a hue change reads as a
delay rather than as motion.

## Selectors

| Selector | Defined in | Does |
| --- | --- | --- |
| `[data-reveal]` | `scroll-reveal.css` | The transition: opacity and transform, 700ms, `--ease-out`. |
| `[data-reveal="out"]` | `scroll-reveal.css` | Hidden state: `opacity: 0`, `translateY(18px)`. |
| `[data-reveal="in"]` | `scroll-reveal.css` | Shown state: `opacity: 1`, no transform. |
| `.logo-stage` | `atoms.css` | Carries `perspective`, on the parent of whatever turns. |
| `.logo-spin` | `atoms.css` | Carries `transform-style: preserve-3d` and `will-change: transform`, on the element that turns. |

## Notes

Every rule in this list is reduced-motion aware on its own - there is no
single switch that disables "motion" as a category. `[data-reveal="out"]`
resolves to the shown state under the preference rather than staying hidden,
because a scroll animation that never plays must not also hide its content.
Anything added to this list needs its own `@media (prefers-reduced-motion:
reduce)` block; there is nothing here that provides one for free.

`will-change: transform` is set on `.logo-spin` specifically and not
inherited by anything using `--ease-out` elsewhere. It costs a compositor
layer per element it is applied to, so it stays on the one thing that is
actually driven at 60fps rather than on every animated element in the sheet.


## Examples


`Reveal` is what actually flips `data-reveal` from `"out"` to `"in"` - the
attribute this page describes is inert on its own, and this is it wired up:

<!-- ::start:showcase demo="reveal" height="380" -->
<!-- ::end:showcase -->

Press Compare. The frames are real viewports, so a layout that breaks at 320
breaks here too rather than in somebody's hands.

## In a page

```tsx
import { Reveal } from "@sushindustries/ui";

export function FeatureSection() {
	return (
		<Reveal>
			<h2>A section that arrives once, on the way past</h2>
			<p>Not scenery repeated on every scroll back up the page.</p>
		</Reveal>
	);
}
```

`Reveal` sets `data-reveal="out"` before it has intersected the viewport and
flips it to `"in"` the first time it does, never back - the CSS in this
package only knows how to animate between the two states, not when to
switch.

## What this example is not

The 700ms duration and the 18px rise are the token's numbers, not
`Reveal`'s - changing how this looks means editing `scroll-reveal.css`,
not passing a prop, because there is no prop for it. This is scenery, timed
the same everywhere on purpose.
