Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 22 additions & 2 deletions static/app/components/core/principles/motion/motion.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -50,15 +50,35 @@ On an otherwise static page, any motion will draw user attention. For this reaso

# Token Reference

Motion tokens for CSS are exposed under the `theme.motion` namespace as pairs of easing and duration values.
Motion tokens are exposed under the `theme.motion` namespace and support both CSS and Framer Motion usage patterns.

```js
const theme = useTheme();

// motion[easing][duration]
theme.motion.smooth.moderate;
```

## Framer Motion

For Framer Motion animations, use the `framer` namespace which provides transition objects ready to use with Framer Motion components:

```tsx
import {useTheme} from '@emotion/react';
import {motion} from 'framer-motion';

function AnimatedComponent() {
const theme = useTheme();

return (
<motion.div
initial={{opacity: 0, y: -16}}
animate={{opacity: 1, y: 0}}
transition={theme.motion.framer.enter.fast}
/>
);
}
```

## Easing

The easing curve of an animation drastically changes our perception of it. These easing tokens have been chosen to provide snappy, natural motion to interactions.
Expand Down
64 changes: 14 additions & 50 deletions static/app/stories/playground/motion.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ import {Text} from 'sentry/components/core/text';
import * as Storybook from 'sentry/stories';

type Motion = Theme['motion'];
type Duration = keyof Motion['enter'];
type Easing = keyof Motion;
type Duration = keyof Motion['framer']['smooth'];
type Easing = keyof Motion['framer'];

const animations = ['x', 'y', 'scale', 'rotate'] as const;

Expand Down Expand Up @@ -44,14 +44,20 @@ export function MotionPlayground() {
<Grid columns="160px 192px" gap="lg" align="center" justify="center">
<Control label="Easing">
<CompactSelect
options={extractTokens(tokens).map(value => ({value, label: value}))}
options={(['smooth', 'snap', 'enter', 'exit'] as const).map(value => ({
value,
label: value,
}))}
value={easing}
onChange={opt => setEasing(opt.value)}
/>
</Control>
<Control label="Duration">
<CompactSelect
options={extractTokens(tokens.enter).map(value => ({value, label: value}))}
options={(['fast', 'moderate', 'slow'] as const).map(value => ({
value,
label: value,
}))}
value={duration}
onChange={opt => setDuration(opt.value)}
/>
Expand Down Expand Up @@ -103,15 +109,16 @@ interface CreateAnimationOptions {
property: (typeof animations)[number];
tokens: Motion;
}

function createAnimation({
property,
duration: durationKey,
duration,
easing,
tokens,
}: CreateAnimationOptions): HTMLMotionProps<'div'> {
const delay = 1;
const defaultState = {x: 0, y: 0, opacity: 1, scale: 1, rotate: 0};
const {duration, ease} = extractDurationAndEase(tokens[easing][durationKey]);
const transition = tokens.framer[easing][duration];

return {
initial: {
Expand All @@ -123,8 +130,7 @@ function createAnimation({
...makeTargetState({property, state: 'end', easing}),
},
transition: {
ease,
duration,
...transition,
delay,
repeat: Infinity,
repeatDelay: delay,
Expand Down Expand Up @@ -176,52 +182,10 @@ const TARGET_CONFIGS: Record<string, TargetConfig> = {
y: TARGET_AXIS,
};

function extractDurationAndEase(css: string): {
duration: number;
ease: [number, number, number, number];
} {
const re =
/^\s*(\d*\.?\d+)(ms|s)\s+cubic-bezier\(\s*(-?\d*\.?\d+)\s*,\s*(-?\d*\.?\d+)\s*,\s*(-?\d*\.?\d+)\s*,\s*(-?\d*\.?\d+)\s*\)\s*$/i;
const match = css.match(re);

if (!match) {
throw new Error(`Invalid timing string: ${css}`);
}

const [, value, unit, x1, y1, x2, y2] = match as [
string,
string,
string,
string,
string,
string,
string,
];

// framer-motion expects duration in seconds
const duration =
unit.toLowerCase() === 'ms'
? Number.parseFloat(value) / 1000
: Number.parseFloat(value);

const ease: [number, number, number, number] = [
Number.parseFloat(x1),
Number.parseFloat(y1),
Number.parseFloat(x2),
Number.parseFloat(y2),
];

return {duration, ease};
}

function makeTargetState({property, state, easing}: TargetStateOptions) {
const config = TARGET_CONFIGS[property as keyof typeof TARGET_CONFIGS] ?? TARGET_AXIS;
return {
[property]: config[easing][state],
opacity: TARGET_OPACITY[easing][state],
};
}

function extractTokens<T extends Record<string, any>>(obj: T) {
return Object.keys(obj) as Array<keyof T>;
}
72 changes: 61 additions & 11 deletions static/app/utils/theme/theme.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import type {CSSProperties} from 'react';
import {css} from '@emotion/react';
import color from 'color';
import type {Transition} from 'framer-motion';

// palette generated via: https://gka.github.io/palettes/#colors=444674,69519A,E1567C,FB7D46,F2B712|steps=20|bez=1|coL=1
const CHART_PALETTE = [
Expand Down Expand Up @@ -252,22 +253,71 @@ const generateTokens = (colors: Colors) => ({
},
});

const generateMotion = () => {
return {
smooth: withDuration('cubic-bezier(0.72, 0, 0.16, 1)'),
snap: withDuration('cubic-bezier(0.8, -0.4, 0.5, 1)'),
enter: withDuration('cubic-bezier(0.24, 1, 0.32, 1)'),
exit: withDuration('cubic-bezier(0.64, 0, 0.8, 0)'),
type MotionName = 'smooth' | 'snap' | 'enter' | 'exit';
type MotionDuration = 'fast' | 'moderate' | 'slow';

type MotionDefinition = Record<MotionDuration, string>;

const motionDurations: Record<MotionDuration, number> = {
fast: 120,
moderate: 160,
slow: 240,
};

const motionCurves: Record<MotionName, [number, number, number, number]> = {
smooth: [0.72, 0, 0.16, 1],
snap: [0.8, -0.4, 0.5, 1],
enter: [0.24, 1, 0.32, 1],
exit: [0.64, 0, 0.8, 0],
};

const withDuration = (
durations: Record<MotionDuration, number>,
easing: [number, number, number, number]
): [MotionDefinition, Record<MotionDuration, Transition>] => {
const motion: MotionDefinition = {
fast: `${durations.fast}ms cubic-bezier(${easing.join(', ')})`,
moderate: `${durations.moderate}ms cubic-bezier(${easing.join(', ')})`,
slow: `${durations.slow}ms cubic-bezier(${easing.join(', ')})`,
};

const framerMotion: Record<MotionDuration, Transition> = {
fast: {
duration: durations.fast / 1000,
ease: easing,
},
moderate: {
duration: durations.moderate / 1000,
ease: easing,
},
slow: {
duration: durations.slow / 1000,
ease: easing,
},
};

return [motion, framerMotion];
};

const withDuration = (easing: string) => {
function generateMotion() {
const [smoothMotion, smoothFramer] = withDuration(motionDurations, motionCurves.smooth);
const [snapMotion, snapFramer] = withDuration(motionDurations, motionCurves.snap);
const [enterMotion, enterFramer] = withDuration(motionDurations, motionCurves.enter);
const [exitMotion, exitFramer] = withDuration(motionDurations, motionCurves.exit);

return {
fast: `120ms ${easing}`,
moderate: `160ms ${easing}`,
slow: `240ms ${easing}`,
smooth: smoothMotion,
snap: snapMotion,
enter: enterMotion,
exit: exitMotion,
framer: {
smooth: smoothFramer,
snap: snapFramer,
enter: enterFramer,
exit: exitFramer,
},
};
};
}

const generateThemeAliases = (colors: Colors) => ({
/**
Expand Down
Loading