Motion and depth
@sushindustries/atomsEvery transition, animation and perspective in the stylesheet, what each is for, and where it is used.
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 |
Where motion is deliberately absent
The device toggle in ShowcaseShowcaseA component in a real iframe at every width it has to survive, side by side, with its source and install commands.@sushindustries/ui · docs 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
.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.
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-filteron the header - made the mobile drawer sixty pixels tall.
Glass, and the blur budget
--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 RevealRevealFades and rises its children the first time they reach the viewport. Never un-reveals.@sushindustries/ui · motion that respects the preference shows its children immediately; leaving them hidden turns an accessibility setting into a blank page. useScrollTurnuseScrollTurnScroll position as a rotation, delivered once per frame. Drives a CSS transform or a three.js object, never React state.@sushindustries/ui · motion 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 | RevealRevealFades and rises its children the first time they reach the viewport. Never un-reveals.@sushindustries/ui · motion, SectionSectionKicker, heading and body, revealing top-down with an 80ms offset.@sushindustries/ui · layout |
.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 | CardCardTitle, optional meta, arbitrary body. Heading level is a prop so the outline stays correct.@sushindustries/ui · layout, archive cards |
| Frame width | atoms.css | ShowcaseShowcaseA component in a real iframe at every width it has to survive, side by side, with its source and install commands.@sushindustries/ui · docs viewport switch |