Motion and depth

@sushindustries/atoms

Every transition, animation and perspective in the stylesheet, what each is for, and where it is used.

6 min read
View as Markdown

Durations, and why they differ

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

WhereDurationWhy that number
Colour on hover160msUnder ~200ms a colour change reads as instant-but-soft. Longer and the interface feels like it is thinking
Card lift260msIt moves 2px. Short travel, but it should feel like weight rather than a twitch
Nav chevron220msRotates 180°, so it is travel, not a state flip
Nav panel in220msAppears in place. It grows into position rather than arriving from somewhere
Mobile drawer420msCrosses the whole screen. A full-screen surface arriving in 200ms reads as a page change, not as something opening
Reveal on scroll700msNot a response to a press. It is scenery, and scenery that hurries draws attention it did not earn
Showcase width320msThe width change is the information - it has to be legible
No transition

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;
}
css

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-filter on 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%);
css

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.

SurfaceFillEdgeBlur
Headeryesyesyes - it is over the page
Nav panelyesyesyes - it is over the page
Mobile draweropaquenono - it sits over a 62% scrim, so the blur composited something already hidden
Cardyesyesno - it is a surface on the page, with nothing behind it worth blurring
Icon tileyesyesno - 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

MotionDefined inUsed by
[data-reveal] fade and riseatoms.cssRevealRevealFades 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 perspectiveatoms.cssScrollSpin, the home page hero
Scroll-driven rotationuse-scroll-turn.tsScrollSpin (CSS), logo-model.tsx (three.js)
nav-panel-inatoms.cssNavBar desktop panels
nav-sheet-in, nav-scrim-inatoms.cssNavBar mobile drawer
Burger bars to crossatoms.cssNavBar toggle
Card liftatoms.cssCardCardTitle, optional meta, arbitrary body. Heading level is a prop so the outline stays correct.@sushindustries/ui · layout, archive cards
Frame widthatoms.cssShowcaseShowcaseA 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