Foundations
Motion
Motion is designed together with every component. It is short, purposeful and comes from tokens; with reduced motion it is not removed but replaced by a calm transition.
Principles
Section titled “Principles”- Purposeful: Every animation has one purpose: feedback, state change, system status, spatial orientation, continuity, or drawing attention to a subtle cue. Motion that is only decoration does not appear on product screens.
- Built with the component: Press, hover, focus, loading, success, enter and exit are designed together with the component and applied through the same presets. A flat button that does not react is not finished.
- Short: Interaction feedback takes 70–110 ms, transitions 150–400 ms. 400 ms is the ceiling; users never wait for an animation.
- Productive first:
productivecurves are the interface default.expressivecurves and springs are for brand moments only and never appear in money flows. - Reduced motion replaces, it does not delete: With “reduce motion” on, scale and travel go away and a short opacity change stays. Nothing that carries information disappears.
Duration
Section titled “Duration”The larger the element and the further it travels, the longer the duration. The Play button runs every duration over the same distance.
| Token | Value | Preview |
|---|---|---|
--bigbang-duration-instantPressed state, check mark | 70 ms | |
--bigbang-duration-fastHover, focus, colour change, short exits | 110 ms | |
--bigbang-duration-normalDropdown, accordion content, list enter | 150 ms | |
--bigbang-duration-slowDialog, toast, success mark | 240 ms | |
--bigbang-duration-slowerChart enter, page-level change (rare), ceiling | 400 ms |
Easing
Section titled “Easing”Each family has three curves. The standard curve is for elements visible from start to end (resizing, moving). The enter curve (-enter) starts fast and settles softly; it is for elements arriving on screen. The exit curve (-exit) starts slow and speeds up as the element leaves for good. linear is for spinners and progress only.
| Token | Value | Preview |
|---|---|---|
--bigbang-easing-productiveDefault standard: press, colour, tab indicator | cubic-bezier(0.2, 0, 0.38, 0.9) | |
--bigbang-easing-expressiveBrand moments; never in money flows | cubic-bezier(0.4, 0.14, 0.3, 1) | |
--bigbang-easing-exitSame as `easing-productive-exit`; kept for existing use | cubic-bezier(0.2, 0, 1, 0.9) | |
--bigbang-easing-productive-enterMenu, dialog, toast, list enter | cubic-bezier(0, 0, 0.38, 0.9) | |
--bigbang-easing-productive-exitMenu, dialog, toast exit | cubic-bezier(0.2, 0, 1, 0.9) | |
--bigbang-easing-expressive-enterBrand moment enter | cubic-bezier(0, 0, 0.3, 1) | |
--bigbang-easing-expressive-exitBrand moment exit | cubic-bezier(0.4, 0.14, 1, 1) | |
--bigbang-easing-linearSpinner, skeleton shimmer, indeterminate progress | cubic-bezier(0, 0, 1, 1) | |
--bigbang-easing-spring-productive-fastSpring curve: switch thumb, press release | linear(0, 0.034, 0.114, 0.214, 0.319, 0.421, 0.514, 0.597, 0.669, 0.73, 0.781, 0.824, 0.859, 0.887, 0.91, 0.929, 0.944, 0.956, 0.965, 0.973, 0.979, 0.983, 0.987, 0.99, 1) | |
--bigbang-easing-spring-productive-baseSpring curve: tab indicator, list reorder, toast stack | linear(0, 0.034, 0.114, 0.215, 0.32, 0.422, 0.515, 0.598, 0.669, 0.73, 0.781, 0.823, 0.858, 0.886, 0.909, 0.928, 0.943, 0.955, 0.964, 0.972, 0.978, 0.983, 0.986, 0.989, 1) | |
--bigbang-easing-spring-productive-slowSpring curve: drawer enter | linear(0, 0.034, 0.114, 0.214, 0.319, 0.421, 0.513, 0.596, 0.667, 0.728, 0.779, 0.821, 0.856, 0.884, 0.907, 0.926, 0.941, 0.953, 0.963, 0.971, 0.977, 0.982, 0.986, 0.989, 1) | |
--bigbang-easing-spring-expressive-baseSpring curve: brand moment, slight overshoot | linear(0, 0.031, 0.108, 0.21, 0.322, 0.435, 0.542, 0.638, 0.722, 0.793, 0.851, 0.898, 0.934, 0.962, 0.982, 0.997, 1.006, 1.012, 1.015, 1.016, 1.016, 1.015, 1.013, 1.012, 1) |
Springs
Section titled “Springs”A spring is defined by physical values instead of a duration: stiffness, damping and mass. The values sit in the DTCG $extensions field; the build checks each spring’s settle time (the moment it stays within 1 % of the target) and generates a CSS linear() curve that follows it. In CSS, use the duration and the curve together: --bigbang-spring-productive-fast and --bigbang-easing-spring-productive-fast. JavaScript engines (Motion, React Native Reanimated) read the same numbers from motionValues.spring.
| Token | Light mode | Dark mode |
|---|---|---|
--bigbang-spring-productive-fastStiffness 900, damping 60 · no overshoot | 0.23s0.23s0.23s | 0.23s0.23s0.23s |
--bigbang-spring-productive-baseStiffness 500, damping 45 · no overshoot | 0.31s0.31s0.31s | 0.31s0.31s0.31s |
--bigbang-spring-productive-slowStiffness 300, damping 35 · no overshoot | 0.4s0.4s0.4s | 0.4s0.4s0.4s |
--bigbang-spring-expressive-baseStiffness 380, damping 31 · slight overshoot, brand moments only | 0.33s0.33s0.33s | 0.33s0.33s0.33s |
When a spring, when a Bézier curve? Use a spring for motion that leaves the user’s hand or can be interrupted: press release, switch, tab indicator, reorder, drawer. If the target changes mid-flight, a spring still looks natural. Use a Bézier curve for opacity, colour, and enter or exit with a fixed duration. Springs on product screens are critically damped (no overshoot).
Semantic motion tokens
Section titled “Semantic motion tokens”Components take duration and easing from tokens named after the pattern, not from the raw scale: --bigbang-motion-<pattern>-<phase>-<property>. For example motion-popover-enter-duration, motion-dialog-scale, motion-list-stagger. When a pattern’s pace changes, only its token changes.
| Token | Light mode | Dark mode |
|---|---|---|
--bigbang-motion-press-duration | 0.07s0.07s0.07s | 0.07s0.07s0.07s |
--bigbang-motion-press-easing | cubic-bezier(0.2, 0, 0.38, 0.9)cubic-bezier(0.2, 0, 0.38, 0.9)cubic-bezier(0.2, 0, 0.38, 0.9) | cubic-bezier(0.2, 0, 0.38, 0.9)cubic-bezier(0.2, 0, 0.38, 0.9)cubic-bezier(0.2, 0, 0.38, 0.9) |
--bigbang-motion-press-scale | 0.970.970.97 | 0.970.970.97 |
--bigbang-motion-release-duration | 0.23s0.23s0.23s | 0.23s0.23s0.23s |
--bigbang-motion-glass-press-scale | 1.021.021.02 | 1.021.021.02 |
--bigbang-motion-hover-duration | 0.11s0.11s0.11s | 0.11s0.11s0.11s |
--bigbang-motion-hover-easing | cubic-bezier(0.2, 0, 0.38, 0.9)cubic-bezier(0.2, 0, 0.38, 0.9)cubic-bezier(0.2, 0, 0.38, 0.9) | cubic-bezier(0.2, 0, 0.38, 0.9)cubic-bezier(0.2, 0, 0.38, 0.9)cubic-bezier(0.2, 0, 0.38, 0.9) |
--bigbang-motion-hover-lift | -2px-2px-2px | -2px-2px-2px |
--bigbang-motion-focus-duration | 0.11s0.11s0.11s | 0.11s0.11s0.11s |
--bigbang-motion-focus-easing | cubic-bezier(0.2, 0, 0.38, 0.9)cubic-bezier(0.2, 0, 0.38, 0.9)cubic-bezier(0.2, 0, 0.38, 0.9) | cubic-bezier(0.2, 0, 0.38, 0.9)cubic-bezier(0.2, 0, 0.38, 0.9)cubic-bezier(0.2, 0, 0.38, 0.9) |
--bigbang-motion-feedback-duration | 0.24s0.24s0.24s | 0.24s0.24s0.24s |
--bigbang-motion-feedback-easing | cubic-bezier(0, 0, 0.38, 0.9)cubic-bezier(0, 0, 0.38, 0.9)cubic-bezier(0, 0, 0.38, 0.9) | cubic-bezier(0, 0, 0.38, 0.9)cubic-bezier(0, 0, 0.38, 0.9)cubic-bezier(0, 0, 0.38, 0.9) |
--bigbang-motion-feedback-scale | 0.60.60.6 | 0.60.60.6 |
--bigbang-motion-feedback-hold | 2s2s2s | 2s2s2s |
--bigbang-motion-selection-duration | 0.11s0.11s0.11s | 0.11s0.11s0.11s |
--bigbang-motion-selection-easing | cubic-bezier(0, 0, 0.38, 0.9)cubic-bezier(0, 0, 0.38, 0.9)cubic-bezier(0, 0, 0.38, 0.9) | cubic-bezier(0, 0, 0.38, 0.9)cubic-bezier(0, 0, 0.38, 0.9)cubic-bezier(0, 0, 0.38, 0.9) |
--bigbang-motion-selection-scale | 0.60.60.6 | 0.60.60.6 |
--bigbang-motion-switch-duration | 0.23s0.23s0.23s | 0.23s0.23s0.23s |
--bigbang-motion-popover-enter-duration | 0.15s0.15s0.15s | 0.15s0.15s0.15s |
--bigbang-motion-popover-exit-duration | 0.11s0.11s0.11s | 0.11s0.11s0.11s |
--bigbang-motion-popover-enter-easing | cubic-bezier(0, 0, 0.38, 0.9)cubic-bezier(0, 0, 0.38, 0.9)cubic-bezier(0, 0, 0.38, 0.9) | cubic-bezier(0, 0, 0.38, 0.9)cubic-bezier(0, 0, 0.38, 0.9)cubic-bezier(0, 0, 0.38, 0.9) |
--bigbang-motion-popover-exit-easing | cubic-bezier(0.2, 0, 1, 0.9)cubic-bezier(0.2, 0, 1, 0.9)cubic-bezier(0.2, 0, 1, 0.9) | cubic-bezier(0.2, 0, 1, 0.9)cubic-bezier(0.2, 0, 1, 0.9)cubic-bezier(0.2, 0, 1, 0.9) |
--bigbang-motion-popover-scale | 0.960.960.96 | 0.960.960.96 |
--bigbang-motion-popover-distance | 4px4px4px | 4px4px4px |
--bigbang-motion-tooltip-delay | 0.4s0.4s0.4s | 0.4s0.4s0.4s |
--bigbang-motion-tooltip-enter-duration | 0.11s0.11s0.11s | 0.11s0.11s0.11s |
--bigbang-motion-tooltip-exit-duration | 0.07s0.07s0.07s | 0.07s0.07s0.07s |
--bigbang-motion-tooltip-distance | 2px2px2px | 2px2px2px |
--bigbang-motion-dialog-enter-duration | 0.24s0.24s0.24s | 0.24s0.24s0.24s |
--bigbang-motion-dialog-exit-duration | 0.15s0.15s0.15s | 0.15s0.15s0.15s |
--bigbang-motion-dialog-enter-easing | cubic-bezier(0, 0, 0.38, 0.9)cubic-bezier(0, 0, 0.38, 0.9)cubic-bezier(0, 0, 0.38, 0.9) | cubic-bezier(0, 0, 0.38, 0.9)cubic-bezier(0, 0, 0.38, 0.9)cubic-bezier(0, 0, 0.38, 0.9) |
--bigbang-motion-dialog-exit-easing | cubic-bezier(0.2, 0, 1, 0.9)cubic-bezier(0.2, 0, 1, 0.9)cubic-bezier(0.2, 0, 1, 0.9) | cubic-bezier(0.2, 0, 1, 0.9)cubic-bezier(0.2, 0, 1, 0.9)cubic-bezier(0.2, 0, 1, 0.9) |
--bigbang-motion-dialog-scale | 0.960.960.96 | 0.960.960.96 |
--bigbang-motion-dialog-distance | 8px8px8px | 8px8px8px |
--bigbang-motion-drawer-enter-duration | 0.4s0.4s0.4s | 0.4s0.4s0.4s |
--bigbang-motion-drawer-exit-duration | 0.24s0.24s0.24s | 0.24s0.24s0.24s |
--bigbang-motion-drawer-exit-easing | cubic-bezier(0.2, 0, 1, 0.9)cubic-bezier(0.2, 0, 1, 0.9)cubic-bezier(0.2, 0, 1, 0.9) | cubic-bezier(0.2, 0, 1, 0.9)cubic-bezier(0.2, 0, 1, 0.9)cubic-bezier(0.2, 0, 1, 0.9) |
--bigbang-motion-drawer-distance | 100%100%100% | 100%100%100% |
--bigbang-motion-toast-enter-duration | 0.24s0.24s0.24s | 0.24s0.24s0.24s |
--bigbang-motion-toast-exit-duration | 0.15s0.15s0.15s | 0.15s0.15s0.15s |
--bigbang-motion-toast-enter-easing | cubic-bezier(0, 0, 0.38, 0.9)cubic-bezier(0, 0, 0.38, 0.9)cubic-bezier(0, 0, 0.38, 0.9) | cubic-bezier(0, 0, 0.38, 0.9)cubic-bezier(0, 0, 0.38, 0.9)cubic-bezier(0, 0, 0.38, 0.9) |
--bigbang-motion-toast-exit-easing | cubic-bezier(0.2, 0, 1, 0.9)cubic-bezier(0.2, 0, 1, 0.9)cubic-bezier(0.2, 0, 1, 0.9) | cubic-bezier(0.2, 0, 1, 0.9)cubic-bezier(0.2, 0, 1, 0.9)cubic-bezier(0.2, 0, 1, 0.9) |
--bigbang-motion-toast-distance | 8px8px8px | 8px8px8px |
--bigbang-motion-collapsible-duration | 0.15s0.15s0.15s | 0.15s0.15s0.15s |
--bigbang-motion-collapsible-easing | cubic-bezier(0, 0, 0.38, 0.9)cubic-bezier(0, 0, 0.38, 0.9)cubic-bezier(0, 0, 0.38, 0.9) | cubic-bezier(0, 0, 0.38, 0.9)cubic-bezier(0, 0, 0.38, 0.9)cubic-bezier(0, 0, 0.38, 0.9) |
--bigbang-motion-collapsible-distance | 4px4px4px | 4px4px4px |
--bigbang-motion-collapsible-icon-duration | 0.15s0.15s0.15s | 0.15s0.15s0.15s |
--bigbang-motion-indicator-duration | 0.31s0.31s0.31s | 0.31s0.31s0.31s |
--bigbang-motion-skeleton-reveal-duration | 0.11s0.11s0.11s | 0.11s0.11s0.11s |
--bigbang-motion-route-enter-duration | 0.15s0.15s0.15s | 0.15s0.15s0.15s |
--bigbang-motion-route-exit-duration | 0.11s0.11s0.11s | 0.11s0.11s0.11s |
--bigbang-motion-route-shared-duration | 0.24s0.24s0.24s | 0.24s0.24s0.24s |
--bigbang-motion-list-enter-duration | 0.15s0.15s0.15s | 0.15s0.15s0.15s |
--bigbang-motion-list-exit-duration | 0.11s0.11s0.11s | 0.11s0.11s0.11s |
--bigbang-motion-list-move-duration | 0.31s0.31s0.31s | 0.31s0.31s0.31s |
--bigbang-motion-list-distance | 4px4px4px | 4px4px4px |
--bigbang-motion-list-stagger | 0.03s0.03s0.03s | 0.03s0.03s0.03s |
--bigbang-motion-value-duration | 0.11s0.11s0.11s | 0.11s0.11s0.11s |
--bigbang-motion-progress-duration | 0.15s0.15s0.15s | 0.15s0.15s0.15s |
--bigbang-motion-chart-enter-duration | 0.4s0.4s0.4s | 0.4s0.4s0.4s |
--bigbang-motion-chart-update-duration | 0.24s0.24s0.24s | 0.24s0.24s0.24s |
State layer
Section titled “State layer”Surfaces that have no hovered colour of their own (cards, list rows, icon buttons) show interaction with a thin layer in their text colour. Opacities:
| Token | Light mode | Dark mode |
|---|---|---|
--bigbang-state-layer-hoveredOn hover | 0.080.080.08 | 0.080.080.08 |
--bigbang-state-layer-focusedOn keyboard focus (with the ring) | 0.10.10.1 | 0.10.10.1 |
--bigbang-state-layer-pressedWhile pressed | 0.10.10.1 | 0.10.10.1 |
--bigbang-state-layer-draggedWhile dragged | 0.160.160.16 | 0.160.160.16 |
Micro-interactions
Section titled “Micro-interactions”The examples below use the motion presets of @bigbang/ui. Components use the same classes in their style recipes; no prop changes.
Press and hover
Section titled “Press and hover”Buttons shrink to 97 % in 70 ms while pressed and spring back on release. Icon buttons and cards use the state layer; an interactive card rises 2 px on hover.
import { motion } from '@bigbang/ui';import { primaryButton, secondaryButton } from '../demo';
export default function Press() { return ( <div className="flex flex-wrap items-center gap-lg"> <button type="button" className={primaryButton}> Confirm </button> <button type="button" className={secondaryButton}> Cancel </button> <button type="button" aria-label="More options" className={`${motion.press} ${motion.stateLayer} inline-flex size-(--bigbang-button-height-md) items-center justify-center rounded-full text-fg-default outline-none focus-visible:focus-outline`} > <svg aria-hidden="true" viewBox="0 0 16 16" fill="currentColor" className="size-4"> <circle cx="3" cy="8" r="1.5" /> <circle cx="8" cy="8" r="1.5" /> <circle cx="13" cy="8" r="1.5" /> </svg> </button> <button type="button" className={`${motion.stateLayer} ${motion.lift} flex w-56 flex-col items-start gap-xs rounded-lg border border-line-subtle bg-surface-raised p-lg text-start outline-none focus-visible:focus-outline`} > <span className="type-label-md text-fg-default">Card title</span> <span className="type-body-sm text-fg-subtle">Card description</span> </button> </div> );}Loading and success
Section titled “Loading and success”While the action runs, the label stays in place (the width does not change) and a spinner appears over it. The success mark grows in from 60 %, stays for two seconds and does not repeat. The result is also announced to screen readers.
import { motion, motionValues } from '@bigbang/ui';import { useEffect, useState } from 'react';import { primaryButton } from '../demo';
type Step = 'idle' | 'busy' | 'done';
export default function Feedback() { const [step, setStep] = useState<Step>('idle'); useEffect(() => { // The first timer stands in for the request; the second returns the button to rest. const next = step === 'busy' ? 'done' : step === 'done' ? 'idle' : null; if (!next) return undefined; const id = setTimeout(() => setStep(next), step === 'busy' ? 1200 : motionValues.motion['feedback-hold']); return () => clearTimeout(id); }, [step]); return ( <div className="flex items-center gap-lg"> <button type="button" aria-disabled={step !== 'idle'} aria-busy={step === 'busy'} onClick={() => step === 'idle' && setStep('busy')} className={`${primaryButton} relative min-w-40`} > <span className={step === 'idle' ? '' : 'opacity-0'}>Save</span> {step === 'busy' ? ( <span className="absolute inset-0 flex items-center justify-center"> <svg aria-hidden="true" viewBox="0 0 16 16" fill="none" className="size-4 animate-spin reduce-motion:animate-none"> <circle cx="8" cy="8" r="6.5" stroke="currentColor" strokeOpacity="0.3" strokeWidth="2" /> <path d="M14.5 8A6.5 6.5 0 0 0 8 1.5" stroke="currentColor" strokeWidth="2" strokeLinecap="round" /> </svg> </span> ) : null} {step === 'done' ? ( <span className={`${motion.success} absolute inset-0 flex items-center justify-center gap-xs`}> <svg aria-hidden="true" viewBox="0 0 16 16" fill="none" className="size-4"> <path d="M3 8.5l3 3 7-7" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" /> </svg> Saved </span> ) : null} </button> <span role="status" className="sr-only"> {step === 'done' ? 'Saved' : ''} </span> </div> );}Field focus and error
Section titled “Field focus and error”The border colour changes in 110 ms; the focus ring appears at once. The error message fades in and drops 4 px. The field does not shake.
import { motion } from '@bigbang/ui';import { useId, useState } from 'react';import { secondaryButton } from '../demo';
export default function Field() { const id = useId(); const [value, setValue] = useState(''); const [invalid, setInvalid] = useState(false); return ( <form noValidate onSubmit={(event) => { event.preventDefault(); setInvalid(value.trim() === ''); }} className="flex w-full max-w-sm flex-col items-start gap-sm" > <label htmlFor={id} className="type-label-md text-fg-default"> Field label </label> <input id={id} value={value} onChange={(event) => setValue(event.target.value)} aria-invalid={invalid || undefined} aria-describedby={invalid ? `${id}-error` : undefined} className={`${motion.field} h-(--bigbang-input-height-md) w-full rounded-(--bigbang-input-radius) border border-line-input bg-surface-raised px-(--bigbang-input-padding-inline) type-body-md text-fg-default outline-none focus-visible:border-line-focus focus-visible:focus-outline-inset aria-invalid:border-line-danger`} /> {invalid ? ( <p id={`${id}-error`} className={`${motion.fieldMessage} type-body-sm text-fg-danger`}> Error message goes here. </p> ) : null} <button type="submit" className={secondaryButton}> Check </button> </form> );}Checkbox and switch
Section titled “Checkbox and switch”The check mark grows in from 60 %. The switch thumb travels on the fast spring; with reduced motion it jumps, and the colour change stays.
import { motion } from '@bigbang/ui';import { useState } from 'react';
const row = 'flex items-center gap-md type-body-md text-fg-default outline-none focus-visible:focus-outline rounded-xs';const state = (on: boolean) => (on ? { 'data-checked': '' } : { 'data-unchecked': '' });
export default function Selection() { const [checked, setChecked] = useState(true); const [on, setOn] = useState(false); return ( <div className="flex flex-col items-start gap-lg"> <button type="button" role="checkbox" aria-checked={checked} onClick={() => setChecked(!checked)} className={row}> <span className={`${motion.press} ${motion.hover} flex size-5 items-center justify-center rounded-xs border ${checked ? 'border-transparent bg-action-primary-default text-action-on-primary-default' : 'border-line-input bg-surface-raised'}`} > <svg aria-hidden="true" viewBox="0 0 16 16" fill="none" {...state(checked)} className={`${motion.selectionMark} size-3.5`}> <path d="M3 8.5l3 3 7-7" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" /> </svg> </span> Option label </button> <button type="button" role="switch" aria-checked={on} onClick={() => setOn(!on)} className={row}> <span className={`${motion.hover} relative h-6 w-10 rounded-full [--switch-travel:16px] ${on ? 'bg-action-primary-default' : 'bg-line-input'}`} > <span {...state(on)} className={`${motion.switchThumb} absolute start-0.5 top-0.5 size-5 rounded-full bg-surface-raised shadow-raised`} /> </span> Sample setting </button> </div> );}Transitions
Section titled “Transitions”Menu, popover and dialog
Section titled “Menu, popover and dialog”A menu opens from its anchor with 96 % scale and 4 px (150 ms) and closes faster (110 ms). A dialog rises 8 px in 240 ms; the scrim darkens at the same pace. A tooltip appears in 110 ms after a 400 ms wait.
import { motion } from '@bigbang/ui';import { useId, useState } from 'react';import { panel, primaryButton, secondaryButton, stage } from '../demo';
// Demo only: shows the motion. Real overlays come from Base UI parts, which also manage focus and Escape.const visibility = (open: boolean) => (open ? { 'data-open': '' } : { 'data-closed': '', inert: true });
export default function Overlays() { const id = useId(); const [menu, setMenu] = useState(false); const [dialog, setDialog] = useState(false); return ( <div className={`${stage} min-h-64 p-xl`}> <div className="flex gap-lg"> <div className="relative"> <button type="button" aria-expanded={menu} aria-controls={`${id}-menu`} onClick={() => setMenu(!menu)} className={secondaryButton}> Actions </button> <div id={`${id}-menu`} {...visibility(menu)} className={`${motion.popover} absolute start-0 top-full z-10 mt-xs flex w-48 flex-col rounded-md border border-line-subtle bg-surface-raised p-xs shadow-overlay`} > {['Download', 'Share', 'Archive'].map((item) => ( <button key={item} type="button" onClick={() => setMenu(false)} className={`${motion.hover} rounded-sm px-md py-sm text-start type-body-md text-fg-default hover:bg-surface-hovered`}> {item} </button> ))} </div> </div> <button type="button" onClick={() => setDialog(true)} className={primaryButton}> Open dialog </button> </div> <div {...visibility(dialog)} onClick={() => setDialog(false)} className={`${motion.scrim} absolute inset-0 z-20 rounded-md bg-surface-scrim`} /> <div role="dialog" aria-labelledby={`${id}-title`} {...visibility(dialog)} className={`${motion.dialog} ${panel} absolute inset-x-xl top-xl z-30 flex flex-col items-start gap-md`} > <h3 id={`${id}-title`} className="m-0 type-heading-sm"> Dialog title </h3> <p className="m-0 type-body-md text-fg-subtle">Dialog description goes here.</p> <button type="button" onClick={() => setDialog(false)} className={secondaryButton}> Close </button> </div> </div> );}A toast rises 8 px into place and the stack moves on the base spring. Toasts that close by themselves stay at least 5 seconds and wait while hovered or focused.
import { motion, motionValues } from '@bigbang/ui';import { useState } from 'react';import { primaryButton, stage } from '../demo';
interface Item { id: number; leaving?: boolean;}
export default function Toast() { const [items, setItems] = useState<Item[]>([]); const dismiss = (id: number) => { setItems((current) => current.map((item) => (item.id === id ? { ...item, leaving: true } : item))); setTimeout(() => setItems((current) => current.filter((item) => item.id !== id)), motionValues.motion['toast-exit-duration']); }; return ( <div className={`${stage} min-h-56`}> <button type="button" onClick={() => setItems((current) => [...current.slice(-2), { id: Date.now() }])} className={primaryButton}> Show a toast </button> <ol aria-live="polite" className="absolute end-0 bottom-0 m-0 flex w-72 list-none flex-col gap-sm p-0"> {items.map((item) => ( <li key={item.id} {...(item.leaving ? { 'data-ending-style': '' } : {})} className={`${motion.toast} flex items-center justify-between gap-md rounded-md bg-surface-inverse px-lg py-md type-body-md text-fg-inverse shadow-overlay`} > Notification text <button type="button" onClick={() => dismiss(item.id)} className={`${motion.press} rounded-sm px-sm type-label-sm text-fg-inverse underline`}> Close </button> </li> ))} </ol> </div> );}Tab indicator and accordion
Section titled “Tab indicator and accordion”The indicator follows the active tab on the base spring. The accordion panel’s height changes at once; the content fades in and drops 4 px, and the chevron turns half a turn. Height is not animated, because animating layout loads the main thread.
import { motion } from '@bigbang/ui';import { type CSSProperties, useId, useLayoutEffect, useRef, useState } from 'react';
export default function Disclosure() { const tabs = ['First tab', 'Second tab', 'Third tab']; const id = useId(); const [active, setActive] = useState(0); const [open, setOpen] = useState(false); const [bar, setBar] = useState({ left: 0, width: 0 }); const listRef = useRef<HTMLDivElement>(null); useLayoutEffect(() => { const tab = listRef.current?.children[active] as HTMLElement | undefined; if (tab) setBar({ left: tab.offsetLeft, width: tab.offsetWidth }); }, [active]); return ( <div className="flex w-full max-w-md flex-col gap-xl"> <div className="relative border-b border-line-subtle"> <div ref={listRef} role="tablist" aria-label="Sections" className="flex"> {tabs.map((tab, index) => ( <button key={tab} type="button" role="tab" aria-selected={index === active} onClick={() => setActive(index)} className={`${motion.hover} px-lg py-sm type-label-md outline-none focus-visible:focus-outline-inset ${index === active ? 'text-fg-default' : 'text-fg-subtle hover:text-fg-default'}`} > {tab} </button> ))} </div> <span aria-hidden="true" style={{ '--active-tab-left': `${bar.left}px`, '--active-tab-width': `${bar.width}px` } as CSSProperties} className={`${motion.indicator} start-0 -bottom-px h-0.5 rounded-full bg-action-primary-default`} /> </div> <div className="group flex flex-col" {...(open ? { 'data-open': '' } : {})}> <button type="button" aria-expanded={open} aria-controls={`${id}-panel`} onClick={() => setOpen(!open)} className={`${motion.hover} flex items-center justify-between rounded-sm py-sm type-label-md text-fg-default outline-none focus-visible:focus-outline`} > Details <svg aria-hidden="true" viewBox="0 0 16 16" fill="none" className={`${motion.collapsibleIcon} size-4`}> <path d="M4 6l4 4 4-4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" /> </svg> </button> {open ? ( <p id={`${id}-panel`} className={`${motion.collapsible} m-0 type-body-md text-fg-subtle`}> Expanded content goes here. </p> ) : null} </div> </div> );}Skeleton to content
Section titled “Skeleton to content”The skeleton shimmer crosses three times (4.5 s) and stops. When content arrives it fades in over 110 ms. With reduced motion the skeleton is still.
import { motion } from '@bigbang/ui';import { useEffect, useState } from 'react';import { secondaryButton } from '../demo';
export default function Skeleton() { const [loading, setLoading] = useState(true); useEffect(() => { if (!loading) return undefined; // Stands in for the request. const id = setTimeout(() => setLoading(false), 1800); return () => clearTimeout(id); }, [loading]); return ( <div className="flex w-full max-w-md flex-col items-start gap-lg"> <button type="button" onClick={() => setLoading(true)} className={secondaryButton}> Reload </button> <div aria-busy={loading} className="flex w-full flex-col gap-sm rounded-lg border border-line-subtle bg-surface-raised p-lg"> {loading ? ( <> <span className={`${motion.skeleton} block h-5 w-1/2 rounded-sm`} /> <span className={`${motion.skeleton} block h-4 w-full rounded-sm`} /> <span className={`${motion.skeleton} block h-4 w-3/4 rounded-sm`} /> </> ) : ( <div className={`${motion.reveal} flex flex-col gap-sm`}> <span className="type-heading-sm text-fg-default">Card title</span> <span className="type-body-md text-fg-subtle">Card description goes here.</span> </div> )} </div> </div> );}List add, remove and stagger
Section titled “List add, remove and stagger”A new item slides 4 px into place in 150 ms; a removed item fades out in 110 ms. In a stagger group items wait 30 ms after each other; from the 8th item on there is no further wait, so a group never runs past 400 ms.
- First item
- Second item
- Third item
import { motion, motionValues } from '@bigbang/ui';import { type CSSProperties, useState } from 'react';import { secondaryButton } from '../demo';
interface Row { id: number; label: string; leaving?: boolean;}
export default function List() { const [rows, setRows] = useState<Row[]>(() => ['First item', 'Second item', 'Third item'].map((label, id) => ({ id, label }))); const [round, setRound] = useState(0); const add = () => setRows((current) => [{ id: Date.now(), label: 'New item' }, ...current]); const remove = (id: number) => { // Keep the row while it fades out, then drop it. setRows((current) => current.map((row) => (row.id === id ? { ...row, leaving: true } : row))); setTimeout(() => setRows((current) => current.filter((row) => row.id !== id)), motionValues.motion['list-exit-duration']); }; return ( <div className="flex w-full max-w-md flex-col gap-md"> <div className="flex gap-md"> <button type="button" onClick={add} className={secondaryButton}> Add </button> <button type="button" onClick={() => setRound(round + 1)} className={secondaryButton}> Replay </button> </div> <ul key={round} className="m-0 flex list-none flex-col gap-xs p-0"> {rows.map((row, index) => ( <li key={row.id} {...(row.leaving ? { 'data-ending-style': '' } : {})} style={{ '--stagger-index': index } as CSSProperties} className={`${motion.listItem} flex items-center justify-between rounded-md border border-line-subtle bg-surface-raised px-lg py-sm type-body-md text-fg-default`} > {row.label} <button type="button" onClick={() => remove(row.id)} className={`${motion.hover} rounded-sm px-sm type-label-sm text-fg-subtle hover:bg-surface-hovered`}> Remove </button> </li> ))} </ul> </div> );}Amounts and progress
Section titled “Amounts and progress”A changed amount cross-fades in 110 ms. Amounts never count up or roll: the in-between values would show a wrong amount. A progress bar fill slides with translate, so its ends stay round at every value; meters and other fills grow with scale-x.
import { motion } from '@bigbang/ui';import { type CSSProperties, useState } from 'react';import { secondaryButton } from '../demo';
const limit = 100;
export default function Value() { const number = new Intl.NumberFormat('en-GB'); const [total, setTotal] = useState(24); return ( <div className="flex w-full max-w-md flex-col items-start gap-lg"> <div className="flex flex-col gap-xs"> <span className="type-body-sm text-fg-subtle">Value</span> {/* A new key remounts the figure, so it cross-fades. Values never count up or roll. */} <span key={total} className={`${motion.reveal} type-heading-lg tabular-nums text-fg-default`}> {number.format(total)} </span> </div> <div className="flex w-full flex-col gap-xs"> <div role="progressbar" aria-label="Progress" aria-valuemin={0} aria-valuemax={limit} aria-valuenow={Math.min(total, limit)} className="h-2 w-full overflow-hidden rounded-full bg-surface-sunken" > <div style={{ '--progress': Math.min(total / limit, 1) } as CSSProperties} className={`${motion.progress} h-full w-full rounded-full bg-action-primary-default`} /> </div> <span className="type-body-sm text-fg-subtle">Progress</span> </div> <button type="button" onClick={() => setTotal(Math.min(total + 8, limit))} className={secondaryButton}> Increase value </button> </div> );}Page transitions and shared elements
Section titled “Page transitions and shared elements”On a route change only the content area changes: the old content fades out in 110 ms and the new one fades in over 150 ms; the shell (header, side navigation) stays. Route changes do not make “everything fade up”. From a list row to its detail page, the row title moves to its new place as a shared element in 240 ms (View Transitions API). Browsers without support switch at once.
Charts
Section titled “Charts”A chart draws in over 400 ms on first appearance and moves to new data in 240 ms. It does not redraw from scratch after a filter or page change. In ECharts, animationDuration and animationDurationUpdate come from motionValues.motion; with reduced motion animation is false.
Choreography
Section titled “Choreography”- A screen has at most 3 stagger groups; within a group items wait at most 40 ms (30 ms by default) and the total stays within 400 ms.
- Order: the container first (panel, dialog), then its content. Content does not wait for the container to finish; it starts with a delay only.
- Enter motion plays on the first appearance of content only; it does not repeat on refresh, filter or pagination.
- At most one large element moves at a time (dialog, drawer or page content).
Reduced motion
Section titled “Reduced motion”The operating system’s “reduce motion” setting, or the in-product override data-motion="reduce", swaps tokens: scales become 1, distances 0, and long fades drop to 110 ms. Component code needs no separate branch. Loops (skeleton, indeterminate) stop with the reduce-motion: variant.
| Item | Full motion | Reduced motion |
|---|---|---|
| Press | 97 % scale, spring release | Colour change only |
| Card hover | 2 px rise | Border colour only |
| Success mark | Grows from 60 %, 240 ms | Fades in over 110 ms, still |
| Menu, tooltip | Scale + travel | Fades in over 110 ms |
| Dialog, drawer | Scale + travel / full-width slide | Fades in over 110 ms |
| Toast, list | 8 / 4 px travel, stagger | Fade, no stagger |
| Switch, tab indicator | Spring travel | Instant move, colour change stays |
| Skeleton, indeterminate progress | Shimmer / sliding bar | Still block / full bar |
| Spinner | Turns | Still icon and text |
| Chart | Draws in | Immediate |
| Brand animation (Rive) | Plays once | Static end frame |
import { motion } from '@bigbang/ui';import { useState } from 'react';import { panel, primaryButton, secondaryButton } from '../demo';
const visibility = (open: boolean) => (open ? { 'data-open': '' } : { 'data-closed': '', inert: true });
export default function Reduced() { const [reduce, setReduce] = useState(true); const [open, setOpen] = useState(false); return ( // data-motion="reduce" applies the same token replacements as the operating-system setting. <div {...(reduce ? { 'data-motion': 'reduce' } : {})} className="relative flex w-full flex-col items-start gap-lg"> <label className="flex items-center gap-sm type-body-md text-fg-default"> <input type="checkbox" checked={reduce} onChange={() => setReduce(!reduce)} className="size-4 accent-(--bigbang-color-action-primary-default)" /> Reduce motion </label> <div className="flex gap-lg"> <button type="button" onClick={() => setOpen(!open)} className={primaryButton}> {open ? 'Hide panel' : 'Show panel'} </button> <button type="button" className={secondaryButton}> Press me </button> </div> <div {...visibility(open)} className={`${motion.dialog} ${panel} w-full max-w-sm type-body-md`}> With reduced motion this panel only fades; nothing scales or moves. </div> </div> );}Money flows
Section titled “Money flows”S1/S2 flows such as payments, transfers, refunds and limits use productive curves and critically damped springs only. These flows have no celebration animation, no overshooting spring, no shake, no counting or rolling amount. On a confirmation screen the success mark arrives once and stays still. An error is told by the field’s colour, icon and text.
Performance
Section titled “Performance”- Only
opacity,transform(scale,translate,rotate) and colour animate. The tab indicator’s width is the one exception.width,height,top,leftandmargindo not animate. will-changeis added only while an animation runs. A canvas animation that is not visible pauses.- Interaction to Next Paint (INP) stays within 200 ms at the 75th percentile; no task longer than 50 ms runs during a transition.
- A product route loads at most 30 kB (gzip) of motion JavaScript; Rive loads late, and only on the route that uses it.
Accessibility
Section titled “Accessibility”- Motion that starts by itself and lasts longer than 5 seconds can be paused (WCAG 2.2.2). Only the spinner loops indefinitely.
- Nothing flashes more than three times per second (WCAG 2.3.1).
- Motion triggered by interaction can be turned off (WCAG 2.3.3, an AAA target): the reduced-motion setting and the in-product override provide this.
- Motion never carries information alone; the state is also given as text and announced to screen readers.
Engines
Section titled “Engines”| Job | Tool |
|---|---|
| Press, hover, focus, colour, enter and exit, skeleton | CSS and motion tokens (default) |
| Route transition, shared element | View Transitions API (same document) |
| Interruptible springs, drag, layout animation | Motion, after its ADR |
| Illustration and brand animation | Rive, after its ADR; vector-only files, self-hosted WASM |
| Ready-made vector animation | dotLottie, after its ADR; self-hosted WASM and worker |
| lottie-web | Not used (needs eval) |
Review checklist
Section titled “Review checklist”- Is the purpose of every animation written down (
motion.purposein the story)? - Do duration, easing and distance come from tokens only? No raw
ms, nocubic-bezier()? - Do only opacity, transform and colour animate?
- What replaces the motion with reduced motion? Was a 0.25× recording reviewed?
- Is there an
expressivecurve or an overshooting spring in a money flow? - Does anything last longer than 5 seconds or loop; can it be paused?
- Does enter motion repeat on refresh or filter?
Guidelines
Section titled “Guidelines”Use tokens and leave reduced motion to them
Section titled “Use tokens and leave reduced motion to them”tv({ base: [motion.press, ‘…’] })duration-700 ease-in-out hover:scale-110Do not count amounts up
Section titled “Do not count amounts up”<span key={total} className={motion.reveal}>countUp(0, balance, 1200)