Skip to content
BigBang

Popular

    ↑↓Navigate↵OpenEscClose
    TR

    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.

    • 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: productive curves are the interface default. expressive curves 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.

    The larger the element and the further it travels, the longer the duration. The Play button runs every duration over the same distance.

    TokenValuePreview
    --bigbang-duration-instant

    Pressed state, check mark

    70 ms
    --bigbang-duration-fast

    Hover, focus, colour change, short exits

    110 ms
    --bigbang-duration-normal

    Dropdown, accordion content, list enter

    150 ms
    --bigbang-duration-slow

    Dialog, toast, success mark

    240 ms
    --bigbang-duration-slower

    Chart enter, page-level change (rare), ceiling

    400 ms

    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.

    TokenValuePreview
    --bigbang-easing-productive

    Default standard: press, colour, tab indicator

    cubic-bezier(0.2, 0, 0.38, 0.9)
    --bigbang-easing-expressive

    Brand moments; never in money flows

    cubic-bezier(0.4, 0.14, 0.3, 1)
    --bigbang-easing-exit

    Same as `easing-productive-exit`; kept for existing use

    cubic-bezier(0.2, 0, 1, 0.9)
    --bigbang-easing-productive-enter

    Menu, dialog, toast, list enter

    cubic-bezier(0, 0, 0.38, 0.9)
    --bigbang-easing-productive-exit

    Menu, dialog, toast exit

    cubic-bezier(0.2, 0, 1, 0.9)
    --bigbang-easing-expressive-enter

    Brand moment enter

    cubic-bezier(0, 0, 0.3, 1)
    --bigbang-easing-expressive-exit

    Brand moment exit

    cubic-bezier(0.4, 0.14, 1, 1)
    --bigbang-easing-linear

    Spinner, skeleton shimmer, indeterminate progress

    cubic-bezier(0, 0, 1, 1)
    --bigbang-easing-spring-productive-fast

    Spring 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-base

    Spring 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-slow

    Spring 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-base

    Spring 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)

    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.

    TokenLight modeDark mode
    --bigbang-spring-productive-fast

    Stiffness 900, damping 60 · no overshoot

    0.23s0.23s0.23s0.23s0.23s0.23s
    --bigbang-spring-productive-base

    Stiffness 500, damping 45 · no overshoot

    0.31s0.31s0.31s0.31s0.31s0.31s
    --bigbang-spring-productive-slow

    Stiffness 300, damping 35 · no overshoot

    0.4s0.4s0.4s0.4s0.4s0.4s
    --bigbang-spring-expressive-base

    Stiffness 380, damping 31 · slight overshoot, brand moments only

    0.33s0.33s0.33s0.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).

    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.

    TokenLight modeDark mode
    --bigbang-motion-press-duration0.07s0.07s0.07s0.07s0.07s0.07s
    --bigbang-motion-press-easingcubic-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-scale0.970.970.970.970.970.97
    --bigbang-motion-release-duration0.23s0.23s0.23s0.23s0.23s0.23s
    --bigbang-motion-glass-press-scale1.021.021.021.021.021.02
    --bigbang-motion-hover-duration0.11s0.11s0.11s0.11s0.11s0.11s
    --bigbang-motion-hover-easingcubic-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-duration0.11s0.11s0.11s0.11s0.11s0.11s
    --bigbang-motion-focus-easingcubic-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-duration0.24s0.24s0.24s0.24s0.24s0.24s
    --bigbang-motion-feedback-easingcubic-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-scale0.60.60.60.60.60.6
    --bigbang-motion-feedback-hold2s2s2s2s2s2s
    --bigbang-motion-selection-duration0.11s0.11s0.11s0.11s0.11s0.11s
    --bigbang-motion-selection-easingcubic-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-scale0.60.60.60.60.60.6
    --bigbang-motion-switch-duration0.23s0.23s0.23s0.23s0.23s0.23s
    --bigbang-motion-popover-enter-duration0.15s0.15s0.15s0.15s0.15s0.15s
    --bigbang-motion-popover-exit-duration0.11s0.11s0.11s0.11s0.11s0.11s
    --bigbang-motion-popover-enter-easingcubic-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-easingcubic-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-scale0.960.960.960.960.960.96
    --bigbang-motion-popover-distance4px4px4px4px4px4px
    --bigbang-motion-tooltip-delay0.4s0.4s0.4s0.4s0.4s0.4s
    --bigbang-motion-tooltip-enter-duration0.11s0.11s0.11s0.11s0.11s0.11s
    --bigbang-motion-tooltip-exit-duration0.07s0.07s0.07s0.07s0.07s0.07s
    --bigbang-motion-tooltip-distance2px2px2px2px2px2px
    --bigbang-motion-dialog-enter-duration0.24s0.24s0.24s0.24s0.24s0.24s
    --bigbang-motion-dialog-exit-duration0.15s0.15s0.15s0.15s0.15s0.15s
    --bigbang-motion-dialog-enter-easingcubic-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-easingcubic-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-scale0.960.960.960.960.960.96
    --bigbang-motion-dialog-distance8px8px8px8px8px8px
    --bigbang-motion-drawer-enter-duration0.4s0.4s0.4s0.4s0.4s0.4s
    --bigbang-motion-drawer-exit-duration0.24s0.24s0.24s0.24s0.24s0.24s
    --bigbang-motion-drawer-exit-easingcubic-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-distance100%100%100%100%100%100%
    --bigbang-motion-toast-enter-duration0.24s0.24s0.24s0.24s0.24s0.24s
    --bigbang-motion-toast-exit-duration0.15s0.15s0.15s0.15s0.15s0.15s
    --bigbang-motion-toast-enter-easingcubic-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-easingcubic-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-distance8px8px8px8px8px8px
    --bigbang-motion-collapsible-duration0.15s0.15s0.15s0.15s0.15s0.15s
    --bigbang-motion-collapsible-easingcubic-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-distance4px4px4px4px4px4px
    --bigbang-motion-collapsible-icon-duration0.15s0.15s0.15s0.15s0.15s0.15s
    --bigbang-motion-indicator-duration0.31s0.31s0.31s0.31s0.31s0.31s
    --bigbang-motion-skeleton-reveal-duration0.11s0.11s0.11s0.11s0.11s0.11s
    --bigbang-motion-route-enter-duration0.15s0.15s0.15s0.15s0.15s0.15s
    --bigbang-motion-route-exit-duration0.11s0.11s0.11s0.11s0.11s0.11s
    --bigbang-motion-route-shared-duration0.24s0.24s0.24s0.24s0.24s0.24s
    --bigbang-motion-list-enter-duration0.15s0.15s0.15s0.15s0.15s0.15s
    --bigbang-motion-list-exit-duration0.11s0.11s0.11s0.11s0.11s0.11s
    --bigbang-motion-list-move-duration0.31s0.31s0.31s0.31s0.31s0.31s
    --bigbang-motion-list-distance4px4px4px4px4px4px
    --bigbang-motion-list-stagger0.03s0.03s0.03s0.03s0.03s0.03s
    --bigbang-motion-value-duration0.11s0.11s0.11s0.11s0.11s0.11s
    --bigbang-motion-progress-duration0.15s0.15s0.15s0.15s0.15s0.15s
    --bigbang-motion-chart-enter-duration0.4s0.4s0.4s0.4s0.4s0.4s
    --bigbang-motion-chart-update-duration0.24s0.24s0.24s0.24s0.24s0.24s

    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:

    TokenLight modeDark mode
    --bigbang-state-layer-hovered

    On hover

    0.080.080.080.080.080.08
    --bigbang-state-layer-focused

    On keyboard focus (with the ring)

    0.10.10.10.10.10.1
    --bigbang-state-layer-pressed

    While pressed

    0.10.10.10.10.10.1
    --bigbang-state-layer-dragged

    While dragged

    0.160.160.160.160.160.16

    The examples below use the motion presets of @bigbang/ui. Components use the same classes in their style recipes; no prop changes.

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

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

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

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

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

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

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

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

      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.

      Value24
      Progress
      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>
      );
      }

      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.

      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.

      • 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).

      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
      With reduced motion this panel only fades; nothing scales or moves.
      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>
      );
      }

      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.

      • Only opacity, transform (scale, translate, rotate) and colour animate. The tab indicator’s width is the one exception. width, height, top, left and margin do not animate.
      • will-change is 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.
      • 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.
      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)
      • Is the purpose of every animation written down (motion.purpose in the story)?
      • Do duration, easing and distance come from tokens only? No raw ms, no cubic-bezier()?
      • Do only opacity, transform and colour animate?
      • What replaces the motion with reduced motion? Was a 0.25× recording reviewed?
      • Is there an expressive curve 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?

      Use tokens and leave reduced motion to them

      Section titled “Use tokens and leave reduced motion to them”
      tv({ base: [motion.press, ‘…’] })
      DoA preset takes duration, easing and reduced motion from tokens.
      duration-700 ease-in-out hover:scale-110
      Don'tLong, arbitrary durations make the interface feel slow and ignore reduced motion.
      <span key={total} className={motion.reveal}>
      DoThe new amount takes its place with a short cross-fade.
      countUp(0, balance, 1200)
      Don'tA balance that counts up shows wrong amounts on the way.