# Animation PS5 React animates with a Motion-style API (`motion.View`, `AnimatePresence`) built on Embedded React's `Animated`, whose animations run in the C engine with no per-frame JavaScript. Tailwind animation classes compile to the same props. The UI is rasterized on the CPU, so motion has a budget: translation is cheap, scale and rotation are limited to small elements, and large fades are the most expensive thing you can animate. [Performance](#performance-on-the-cpu-renderer) lists the rules; [Design guidelines](#design-guidelines) gives the motion tokens. `apps/motion-lab` shows every pattern on this page. ## Quick start ```jsx import {AppRegistry, View, Text, FocusScope, motion, transitions} from '@ps5-react/core'; const items = ['Play', 'Store', 'Settings']; function Menu() { return ( {items.map(label => ( {label} ))} ); } AppRegistry.registerComponent('menu', () => Menu); ``` The row fades and rises in once; each card lifts while it has focus (see [NAVIGATION.md](NAVIGATION.md)). No JavaScript runs while a value moves: the engine drives it, and React renders only when state changes. ## Runtime API (`@ps5-react/core`) Also re-exported unchanged from Embedded React: `Animated`, `useAnimatedValue`, `Easing`, `LayoutAnimation`, `Pressable`. ### `motion.View`, `motion.Text`, `motion.Image`, `motion.create(Component)` Props, in addition to the wrapped component's: | Prop | Type | Meaning | | --- | --- | --- | | `initial` | target \| variant label \| `false` | State at mount; `false` starts at `animate` without animating | | `animate` | target \| variant label | State to animate to whenever it changes (deep-compared) | | `exit` | target \| variant label | State animated before removal; needs an `AnimatePresence` parent | | `transition` | transition | Default transition; per-key overrides as `transition={{x: {...}}}` | | `variants` | `{[label]: target}` | Named targets; labels propagate to motion children | | `whileFocus` | target \| label | Applied while the element has focus (its `focused` prop when given) | | `whileSelect` | target \| label | Applied while `selected` is true | | `whilePress` | target \| label | Applied while `pressed` is true | | `onAnimationComplete` | `(definition) => void` | Called when `animate` settles | A **target** uses only animatable keys: `opacity` (0..1), `x`, `y` (px, before render scaling — the same logical px as Tailwind), `scale`, `scaleX`, `scaleY`, `rotate` (degrees). Other keys throw in development builds of the motion module with an actionable message. Colors are not animatable by the engine: use crossfading layers. Priority when several apply: `whilePress` > `whileSelect` > `whileFocus` > `animate`; keys not named by a higher one fall back to the lower ones. A **transition**: | Key | Default | Meaning | | --- | --- | --- | | `type` | `'spring'` for x/y/scale/rotate, `'tween'` for opacity | `'spring'` \| `'tween'` | | `duration` | 0.3 | Seconds (tween) | | `delay` | 0 | Seconds | | `ease` | `'easeOut'` | `'linear'`, `'easeIn'`, `'easeOut'`, `'easeInOut'`, `'circOut'`, `'backOut'`, `'anticipate'` or a `[x1, y1, x2, y2]` cubic bezier | | `stiffness`, `damping`, `mass` | 300, 30, 1 | Spring | | `repeat`, `repeatType` | 0, `'loop'` | `Infinity` loops; `'reverse'` alternates | | `staggerChildren`, `delayChildren`, `staggerDirection` | 0, 0, 1 | Variant orchestration (seconds) | `transitions` exports presets from the motion tokens: `transitions.focus` (spring, quick, no overshoot), `transitions.panel`, `transitions.screen` (tween 0.45 s quintOut), `transitions.exit` (tween, shorter, easeIn), `transitions.pop` (spring with overshoot, for small elements). ### `AnimatePresence` `` keeps removed keyed motion children mounted until their `exit` finishes. `mode="wait"` enters the next child only after the previous one left. ### Engine limits that the API respects - Animations bind opacity and transforms only; scale/rotate render only on elements whose laid-out size fits the transform scratch buffer (512×512 physical px in the default profile). Translate and opacity have no size limit; full-screen opacity is expensive, except on a container holding only an ``, which the host fades without repainting ([IMAGES.md](IMAGES.md)). - On the PS5 the cost of a change is mostly its raster, about 5 ns per changed pixel at 1080p (more under scaled images and gradients), plus about 0.5 ms to upload 1920×540 rows through the presenter's buffer textures. Full-screen changes still take a frame or more each, so prefer animating small elements. - Animated opacity applies to View-family nodes; `motion.Text`/`motion.Image` wrap themselves in a View to fade. - Values are allocated lazily per animated key and released on unmount. ## Tailwind classes (compiled by tools/tailwind) An element using any of these classes becomes the matching `motion.*` component (host primitives only); the classes become motion props: | Classes | Becomes | | --- | --- | | `animate-in` with `fade-in[-N]`, `slide-in-from-{top,bottom,left,right}[-N]`, `zoom-in[-N]`, `spin-in[-N]` | `initial` (the "from" values) and `animate` (identity) | | `animate-out` with `fade-out[-N]`, `slide-out-to-*`, `zoom-out[-N]`, `spin-out[-N]` | `exit` | | `duration-N`, `delay-N`, `ease-linear/in/out/in-out` | `transition` (ms → s) | | `transition`, `transition-all`, `transition-transform`, `transition-opacity` | Opacity/transform keys of state variants (`focused:`, `selected:`, …) animate via `animate` instead of snapping | | `animate-spin`, `animate-pulse`, `animate-bounce`, `animate-ping` | Repeating `animate` + `transition` | `fade-in-N` / `zoom-in-N` take Tailwind opacity / scale numbers (`zoom-in-95` → from 0.95); `slide-in-from-bottom-4` takes spacing (16 logical px). Non-animatable keys in a variant (colors, borders) still switch instantly. ```jsx ``` In the second example the scale animates and the background switches at once. ## Recipes ### Staggered entrance Variant labels set on a parent reach every motion descendant that has `variants` and no `animate` of its own. The parent's `staggerChildren` delays each child after the previous one: ```jsx const grid = {hidden: {}, shown: {transition: {staggerChildren: 0.05, delayChildren: 0.08}}}; const card = {hidden: {opacity: 0, y: 24}, shown: {opacity: 1, y: 0, transition: transitions.panel}}; {items.map(item => )} ``` Keep the whole sequence under about 400 ms: with ten cards, 30–50 ms per step. ### Focus: lift plus a gliding highlight Lift the focused card a little (`y: -6`, `scale: 1.06`) and let one highlight travel between cards instead of drawing a border on each. The highlight moves by translation only, so its size does not matter. Navigation moves focus; the cards only report it through `onFocus`/`onBlur` so the highlight knows where to go: ```jsx const [current, setCurrent] = useState(-1); const col = current % COLS, row = Math.floor(current / COLS); {items.map((item, i) => ( open(item)} whileFocus={{y: -6, scale: 1.06}} onFocus={() => setCurrent(i)} onBlur={() => setCurrent(-1)} transition={transitions.focus} className="w-[200px] h-[140px] rounded-2xl bg-slate-800" /> ))} ``` Fixed card sizes make the highlight position plain arithmetic. Keep scaled cards within 256 logical px at a 2560-wide render (see [Performance](#performance-on-the-cpu-renderer)). ### Focus: a light running around the border The PS5 focus ring is a light travelling around the focused item's rounded border, bright at its head and fading in a long tail. Four `View` styles draw it, over the content, inside the box, following its `borderRadius`: | Style | Meaning | | --- | --- | | `borderSweepColor` | Colour at the head (its alpha scales the whole light) | | `borderSweepWidth` | Ring thickness in render px; 0 draws nothing | | `borderSweepLength` | Tail as a fraction of the perimeter (default 0.3) | | `borderSweepPhase` | Head position, 0–1 clockwise from the top edge's left end; wraps at 1 | Animate the phase with an `Animated` value: the engine moves it every frame with no JavaScript, and a phase-only change repaints the bands along the four edges the ring reaches (about a quarter of a card), not the card: ```jsx function FocusLight({style}) { const phase = useAnimatedValue(0); useEffect(() => { const run = Animated.loop(Animated.timing(phase, {toValue: 1, duration: 2400, easing: Easing.linear})); run.start(); return () => run.stop(); }, []); return ( ); } ``` The classic rotating-gradient border is the other way to draw it: a conic gradient turning behind the item, seen only through its border. The `borderGradient` style draws exactly that, and `borderGradientAngle` turns it: ```jsx const angle = useAnimatedValue(0); // Animated.loop(Animated.timing(angle, {toValue: 360, duration: 2400, easing: Easing.linear})).start(); ``` | `borderGradient` key | Meaning | | --- | --- | | `type` | `'conic'` or `'radial'` | | `width` | Ring thickness in render px | | `angle` | Conic: start angle in degrees, CSS `conic-gradient(from …)`: 0 up, clockwise. Radial: loop position (below) | | `size` | Radial only: background size as a multiple of the box (CSS `background-size: 300%` is 3) | | `stops` | Up to 6 `{color, offset}` around the circle, or from the centre out; colours keep their alpha | `type: 'radial'` is Magic UI's ShineBorder: a CSS `radial-gradient` (ellipse, farthest-corner) on a background `size` times the box, its position looping `0% 0%` → `100% 100%` → `0% 0%`, seen through the border. One 360° turn of the angle is one loop, so the conic example's linear 0–360 timing drives it too: ```jsx borderGradient: {type: 'radial', width: 3, size: 3, stops: [ {color: '#ffffff00', offset: 0.25}, {color: '#ffffff', offset: 0.5}, {color: '#ffffff00', offset: 0.75}]} ``` `borderGradientAngle` (degrees) overrides `angle` and animates on the native driver, repainting only the ring's bands like the sweep. A conic gradient turns around the centre, so on a long card the light moves faster along the short sides; `borderSweep*` moves at one speed along the perimeter. Neither has a Tailwind class: their stops and animated values need a style object. Mount it only on the focused item (`useIsFocused`), so one light runs at a time. Inside a scaled element (`whileFocus={{scale: 1.04}}`) the light scales with it, but a phase step then repaints the element's whole scaled box, as any change under a transform does; on a large card, keep the light outside the scaled subtree, or lift the card with `y` instead of `scale`. For a soft halo, put a second, wider sweep with a lower alpha behind the first. ### A light across a card A gloss that sweeps over a focused card is a gradient band translated across it. Two View styles make it look like light on glass rather than a stripe pasted on top: - `roundedClip: true` clips a View's children to its rounded border box, anti-aliased exactly as a rounded background of that box is, so the band runs out through the corners. It implies `overflow: 'hidden'`; `overflow: 'hidden'` alone still clips to the rectangle. Nested rounded clips multiply. - `mixBlendMode: 'plus-lighter'` adds what a View and its children draw to what is beneath, saturating at white, so highlights brighten the art instead of washing it out. Each primitive adds, so overlapping layers add up. Under a translucent or transformed ancestor the light adds within that group, which then composites normally. ```jsx ``` The band repaints only the strip it covers each frame. Neither style has a Tailwind class. Content under either never takes the pixel-moving fast paths (scroll and translate copies) or becomes a host layer, so keep them on small, short-lived elements such as the focused card's light. ### Page transitions Give each page a `key` inside `AnimatePresence mode="wait"`: the old page leaves fast, then the new one slides in. Translate the full-screen container and fade only its small children, which receive the same labels; never fade or scale the container itself: ```jsx const page = { hidden: {x: 90}, shown: {x: 0, transition: {...transitions.screen, staggerChildren: 0.05}}, gone: {x: -60, transition: transitions.exit}, }; const card = { hidden: {opacity: 0, y: 24}, shown: {opacity: 1, y: 0, transition: transitions.panel}, gone: {opacity: 0, transition: transitions.exit}, }; ``` Cards inside `Page` that declare `variants={card}` fade in staggered and fade out with the page. A label change renders only the elements that use labels: those with `variants` and no `animate` of their own, and those that pass labels on. Elements with their own `animate` or only `whileFocus` stay as they are. For tabs, `Screens` (NAVIGATION.md, "Tabs that keep their pages") runs the same leave-then-enter switch with these labels but keeps every page it has shown mounted, so going back to a page does not build it again. A full-screen page that only slides, such as a details sheet over the screen it came from, is moved by copying the pixels it already painted when it is one opaque plain `View` (a solid background, no radius, border, gradient or shadow), optionally inside paint-free single-child wrappers, with its whole subtree inside its box and no translucent or transformed ancestor. Each frame then repaints only the strip it uncovers, the area it left and anything drawn over it, instead of the whole screen. Give such a sheet a solid background rather than a translucent one, and animate `x` or `y`, not scale or opacity. ### Toasts and other pop-ins Small, short-lived elements can overshoot. Enter with `transitions.pop`, leave faster with `transitions.exit`, and keep the element within the scale limit: ```jsx {toast && ( )} ``` ### Idle and ambient motion Loops use `repeat: Infinity`. Keep them small and slow: ```jsx ``` Do not place a loop inside an element that is scaled or rotated: a change inside a transformed node repaints the whole node every frame. Lift such containers with `y` instead. ### Changing colors Colors switch instantly. To make a color change feel animated, stack two layers and crossfade the top one, preferably on a small element: ```jsx {label} ``` ### Lower level: `Animated` `motion` covers declarative state changes. For sequences, `interpolate`, or imperative control, use the re-exported `Animated` and `useAnimatedValue` directly. They follow Embedded React's API: durations in milliseconds, `Easing` tokens, and no need for `useNativeDriver`. `LayoutAnimation.configureNext()` tweens every node whose layout rectangle changes in the next commit. ## Performance on the CPU renderer Embedded React rasterizes every changed pixel on the CPU, then OpenGL presents the framebuffer. An animation costs roughly the area it repaints each frame, plus an offscreen copy when that area is faded or transformed. - **Translate (`x`, `y`) is cheap and unlimited.** It repaints the old and new rectangles and needs no offscreen copy. Prefer it for focus, sliding highlights, and page transitions. - **Scale and rotate only on small elements.** The untransformed subtree is copied into a transform buffer of 512×512 physical px. At a 2560-wide render with `baseWidth` 1280, that is **256×256 logical px**; larger elements do not render scaled. Every change inside a transformed node repaints the whole node. - **Opacity composites through scratch strips.** A small fade is cheap. A full-screen fade blends every pixel each frame and is the most expensive animation available. Fade cards, toasts, and highlights rather than screens, and keep screen fades short. - **Colors are not animatable.** Crossfade two layers, as shown above. - **Values are pooled.** The engine has a fixed pool of animated values. `motion` allocates one per animated key on first use and releases it on unmount. Do not animate hundreds of elements at once or keep unbounded lists of motion components mounted. - **Animate values, not state.** `setState` on a timer re-renders and re-commits the tree on every tick. A `motion` target or an `Animated` value moves in C with no JavaScript. ### Reading the frame-time log Both hosts always print a summary every two seconds (illustrative values, not a PS5 measurement): ```text [PS5-REACT] frame: fps=59.9 total avg=16.7ms p95=17.1 max=21.4 | input=0.1 update=3.2 present=1.9 (upload=0.8 draw=1.1 up=2100KB) swap=11.4 | cpu=0.6 cores (main=4.8 present=0.9 swap=0.2) | js=0.4 (dispatch=0.1 react=0.2 marshal=0.1) layout=0.1 raster=2.7 (prepass=0.1 render=1.6 blit=1.0) dirty=310kpx blit=820kpx ``` On the PS5, the line goes to the kernel log (`sceKernelDebugOutText`), which your loader's klog viewer shows, and to stdout. The macOS preview prints it to the terminal running `npm run dev` or `npm run preview`; the desktop self-test omits it. | Field | Meaning | | --- | --- | | `fps` | Frames per second over the two-second window | | `total avg`, `p95`, `max` | Time between frame starts in ms: mean, 95th percentile, and worst | | `input` | Controller polling and action dispatch to JavaScript, including any render a handler causes | | `update` | The JavaScript pump, React commits, layout, and CPU rasterization | | `present` | Uploading the damaged framebuffer rectangles and drawing them with OpenGL, split into `upload` (texture uploads, including newly shown layer images), `draw` (draw calls) and `up` (mean kilobytes uploaded per frame). Both are CPU time issuing the work; the GPU may finish it later, inside `swap` | | `swap` | Buffer swap, including any wait for vertical sync. A frame that changed nothing is not presented: the host sleeps to the next vertical sync instead | | `cpu` | CPU time of the whole process over wall time, in cores: render workers, image decoding and network threads included | | `main`, `present`, `swap` (after `cpu`) | CPU ms per frame of the render thread, then its part in `present` and `swap`; wall time minus CPU time there is waiting | | `js` | JavaScript, split into `dispatch` (handlers, timers, microtasks), `react` (component renders) and `marshal` (props pushed into the engine) | | `layout` | Flex layout and text measurement | | `raster` | CPU rasterization, split into `prepass` (deciding what to repaint), `render` (compositing) and `blit` (framebuffer writes) | | `dirty`, `blit` | Thousands of pixels repainted, and written, per frame; `blit` well above `dirty` means overlapping layers | Phase values are per-frame means in ms. `total` minus the four host phases is the engine's animation tick plus loop overhead. The second group comes from the engine's own instrumentation (`ERUI_PERF_STATS`, enabled in `native/ps5/CMakeLists.txt`) and adds `js + layout + raster ≈ input + update`. A large `swap` with a small `update` means the frame finished early and waited for vsync, which is healthy. When `fps` drops, look at `update`: if it rises only while an animation runs, the animation repaints too much (a large fade, a loop inside a scaled element, or a large image drawn at a size other than its baked size, which is rescaled on every repaint). A `p95` or `max` far above the mean points at hitches, such as a page transition mounting a large tree in one frame. Desktop timings do not predict PS5 timings; measure on the console before claiming a frame rate. Frames longer than 33 ms also print one line each (at most eight per window), with the same split for that frame and the bounding box of its repaint: ```text [PS5-REACT] slow frame: 41.2ms | js=24.1 (dispatch=0.6 react=22.0 marshal=1.0) layout=0.2 raster=9.8 (prepass=0.1 render=6.4 blit=3.2 sweep=0.0) present=2.6 (upload=1.1 draw=1.5) other=4.5 | dirty=2356x1250@142,190 1849kpx blit=7013kpx ``` `other` is host work outside those phases, including the vsync wait. A PS5 test deploy can lower the 33 ms threshold with `dev/slow-frame-ms.txt` in the app folder, and report every slow frame instead of the first eight per two-second window with a larger limit in `dev/slow-frame-lines.txt`. The host clamps the animation step to 50 ms, so frames beyond that make animations run in slow motion. #### Reproducible profiling The preview replays a scripted input sequence when `PS5_REACT_INPUT_SCRIPT` is set, through the same dispatch path as the keyboard and controller. Steps are separated by commas or whitespace: an action (`up`, `down`, `left`, `right`, `confirm`, `back`, `l1`, `r1`, `l2`, `r2`, `triangle`, `square`, `options`) takes one frame, `action*N` repeats it at the held-key repeat interval (110 ms), `wait:MS` pauses for wall-clock milliseconds, `quit` closes the preview, `shot:NAME` saves the frame on screen (letters, digits, `_` and `-`; see [Screenshots](#screenshots)), and `swipe:DIRECTION:TRAVEL` slides a touchpad finger `TRAVEL` (up to 0.5 of the pad) from its middle over 16 frames and lifts it ([NAVIGATION.md](NAVIGATION.md#touchpad)). `hold:BUTTON[:MS]` holds a `GamepadState` button (`touchpad`, `l3`, `cross`, ...) for MS or until `release:BUTTON`, and `trigger:l2|r2:VALUE[:MS]` sets a trigger's travel (0–1; past half it is pressed, 0 lets go). They return at once, so a hold reads `trigger:r2:1,wait:800,trigger:r2:0`; a button with an action (Cross: `confirm`, L2 past half travel: `l2`) dispatches it as it goes down, once. `ps5_drive.py script ""` runs steps in a running preview. `PS5_REACT_SLOW_FRAME_MS` changes the slow-frame threshold. Each action is echoed as a `script:` line, so the log reads as a timeline. Run the preview binary directly, with the bundle of the layout it was built for, to set them: ```sh PS5_REACT_SANDBOX=.build/my-app/sandbox PS5_REACT_SLOW_FRAME_MS=20 \ PS5_REACT_INPUT_SCRIPT="wait:3000,right*3,wait:1500,confirm,wait:2000,back,wait:2000,quit" \ .build/my-app/desktop/ps5-react-preview .build/my-app/generated/3840x2160/app.bundle.js ``` The variables have no effect on `--self-test`. On the PS5, the host reads the same syntax from `dev/input-script.txt` in the app folder (`/app0/dev/input-script.txt`) when that file exists; commas, spaces and newlines all separate steps, and the `script:` lines go to the kernel log. The file is never part of a build: add it to the built title folder (`dist//dev/`) for a test deploy only, and end the script with `quit` so the run closes itself. While such a test deploy runs, the PS5 host also takes live commands: about once a second it reads `dev/commands.txt` and runs, in the same syntax, every line of the form ` ` whose sequence number it has not seen (lines present at launch are skipped). Writing the file with a new number, for example the current time in milliseconds, runs its steps once; each line is echoed as a `command:` line in the kernel log. The host polls only when the app folder has a `dev` directory, which a build never creates. #### Profiling JavaScript When a slow frame's time is in `js` or `react`, a sampling profiler shows which functions it went to. Put the sampling interval in microseconds (`1000` is a good start) in `dev/profile.txt` of a PS5 test deploy, or set `PS5_REACT_PROFILE` to an output path for the preview. About once per interval QuickJS's interrupt handler records the running stack, and when the run ends the host writes folded stacks to `dev/profile.folded` (or the given path): one line per stack, outermost frame first, frames as `name@line`, then the microseconds charged to it. Flame-graph tools read the format. Lines are those of `.build//generated/app.bundle.js`, which also identifies React's minified internals. Time inside a native call is charged to the first sample after it returns, so bridge and engine calls are undercounted; the frame-time log measures those. #### Screenshots `shot:NAME` in a script or a live command saves the frame currently on screen, at half resolution (960 × 540 for a 1080p render), as a 24-bit `NAME.bmp`. It reads the software framebuffer between frames, so it shows exactly what was presented; writing the file takes that frame about 100 ms on the PS5, so leave a `wait` around shots inside a measured sequence. On the PS5 the file goes to the title's `dev/` folder (`/data/homebrew//dev/NAME.bmp`); the preview writes it to `.build//sandbox/temp0/`. Each shot logs a `shot : saved` line. Without a `dev` folder no script or command runs, so a build pays nothing. With the PS5Upload desktop app running, fetch shots and convert them to PNG: ```sh PS5_ADDR= python3 tools/ps5_shots.py PPSA99058 shots/ start game-page ``` Without names it fetches every `.bmp` in `dev/`. The script only reads; it calls PS5Upload's local `/api/transfer/download` (`kind: "file"`), the same as downloading `dev/NAME.bmp` by hand. Elevated titles (`filesystemAccess: "console"`) see `/app0` only as a logical path; the host resolves `dev/` files through the same mapping as the native API. ## Design guidelines Motion should explain where focus went and where content came from. On a TV, viewed from across the room, small fast movements read as responsive; large slow ones read as lag. | Token | Duration | Curve | Use | | --- | --- | --- | --- | | Focus | 120–200 ms | Spring without overshoot (`transitions.focus`) | Focus lift, gliding highlight, tab pill | | Panel | 250–350 ms | Ease-out (`transitions.panel`) | Cards, panels, drawers, list entrances | | Screen | 400–500 ms | Quint-out (`transitions.screen`) | Page and screen changes | | Exit | Shorter than the matching entrance | Ease-in (`transitions.exit`) | Anything leaving | | Pop | Short spring with overshoot (`transitions.pop`) | | Toasts, badges, small confirmations | | Stagger | 30–90 ms per item | | Grids and lists; keep the sequence under about 400 ms | - **Exits are shorter than entrances.** The user has already decided to leave; do not make them watch it. - **Overshoot only on small elements.** A bouncing grid or screen feels unstable; a bouncing badge feels alive. Focus never overshoots. - **Crossfades are asymmetric.** Fade the outgoing layer out quickly and bring the incoming one in slightly later and slower, so the two never sit at 50 % together as a muddy blend. - **Move with intent.** Enter from the direction of navigation (the next page from the right, the old one leaving to the left) and keep distances short: 60–90 px for screens, 6–24 px for cards. - **Idle motion stays small.** Breathing glows and spinners should be slow (a second or more per cycle), low in contrast, and limited to small elements, so they never compete with focus or cost frame time. - **One focus signal.** Combine a lift with a single highlight; do not also pulse, recolor, and scale the same element.