Skip to content

Instantly share code, notes, and snippets.

@marcusstenbeck
Last active November 21, 2022 20:33
Show Gist options
  • Save marcusstenbeck/0731354da1b8894839a387b00cb021eb to your computer and use it in GitHub Desktop.
Save marcusstenbeck/0731354da1b8894839a387b00cb021eb to your computer and use it in GitHub Desktop.
Remotion + Popmotion
import * as popmotion from 'popmotion';
import { pop } from './RemotionPop';
const easeOutQuint = popmotion.cubicBezier(0.22, 1, 0.36, 1);
const ExampleComponent = () => {
// you can animate any normal HTML element, ex. `pop.h2` or `pop.span`
return (
<pop.div
// animationOptions is optional, but usually you would set this
animationOptions={{
elapsed: -500,
duration: 1000,
ease: popmotion.linear,
}}
style={{
// shorthand, will animate using the animationOptions/defaults
opacity: [0, 1],
// override animationOptions/defaults
// here setting a new easing function for this prop
transform: {
value: ['translateY(20px) scale(0.5)', 'translateY(0px) scale(1)'],
ease: easeOutQuint
},
// regular CSS property
backgroundColor: 'salmon'
}}
>
Remotion + Popmotion
</pop.div>
);
};
export default ExampleComponent;
import * as popmotion from 'popmotion';
import { Animation, AnimationOptions, KeyframeOptions } from 'popmotion';
import React, {
CSSProperties,
DetailedHTMLFactory,
ForwardRefExoticComponent,
HTMLAttributes,
PropsWithoutRef,
ReactHTML,
RefAttributes,
useMemo,
} from 'react';
import { useSeconds } from './useMakeDriver';
function useSeconds() {
const config = useVideoConfig();
const currentFrame = useCurrentFrame();
return currentFrame / config.fps;
}
function playTime(
time: number,
options: Omit<PopKeyframeOptions<number>, 'from' | 'to'>
) {
const elapsed = options?.elapsed ?? 0;
const duration = options?.duration ?? 1000;
const direction = options?.repeatType ?? 'loop';
const loops = options?.repeat ?? 1;
const offsetTime = Math.max(0, time + elapsed);
let adjustedTime = offsetTime;
const currentCycle = Math.floor(offsetTime / duration);
const hasFinishedLooping = currentCycle >= loops;
// only keep the cycle that hasn't finished
adjustedTime = adjustedTime % duration;
const isOddCycle = currentCycle % 2 !== 0;
if ((direction === 'mirror' || direction === 'reverse') && isOddCycle) {
adjustedTime = duration - adjustedTime;
}
if (hasFinishedLooping) {
const isFinalCycleOdd = (loops - 1) % 2 !== 0;
const didFinishReversed =
(direction === 'mirror' || direction === 'reverse') && isFinalCycleOdd;
adjustedTime = didFinishReversed ? 0 : duration;
}
return adjustedTime;
}
function createPopComponent<Props extends PopProps, Instance>({ Component }) {
const PopComponent = (props: Props, externalRef?: React.Ref<Instance>) => {
const seconds = useSeconds();
const { style, animationOptions, ...htmlProps } = props;
const animations: Record<
string,
{
options: PopKeyframeOptions;
animation: Animation<unknown>;
}
> = useMemo(() => {
if (!style || typeof style === 'string') return {};
return Object.entries(style).reduce((acc, [key, value]) => {
if (!value || typeof value === 'string' || typeof value === 'number') {
return acc;
}
let options: Record<string, unknown> = {};
if (Array.isArray(value)) {
options.to = value;
options.duration = animationOptions.duration;
options.ease = animationOptions.ease;
} else {
options = { ...value };
options.duration = value.duration ?? animationOptions.duration;
options.ease = value.ease ?? animationOptions.ease;
}
return {
...acc,
[key]: {
options,
animation: popmotion.keyframes(options),
},
};
}, {});
}, [animationOptions.duration, animationOptions.ease, style]);
const interpolatedStyle: CSSProperties = useMemo(() => {
const time = seconds * 1000;
const elementTime = playTime(time, animationOptions);
return Object.entries(animations).reduce((acc, [key, v]) => {
const duration = v?.options?.duration ?? animationOptions?.duration;
const ease = v?.options?.ease ?? animationOptions?.ease;
const propertyTime = playTime(elementTime, {
...v.options,
duration,
ease,
});
return { ...acc, [key]: v.animation.next(propertyTime).value };
}, {});
}, [animationOptions, animations, seconds]);
const finalStyle = useMemo(
() => ({ ...style, ...interpolatedStyle }),
[interpolatedStyle, style]
) as CSSProperties;
return <Component ref={externalRef} {...htmlProps} style={finalStyle} />;
};
return React.forwardRef(PopComponent);
}
type UnionStringArray<T extends Readonly<string[]>> = T[number];
const htmlElements = [
'a',
'abbr',
'address',
'area',
'article',
'aside',
'audio',
'b',
'base',
'bdi',
'bdo',
'big',
'blockquote',
'body',
'br',
'button',
'canvas',
'caption',
'cite',
'code',
'col',
'colgroup',
'data',
'datalist',
'dd',
'del',
'details',
'dfn',
'dialog',
'div',
'dl',
'dt',
'em',
'embed',
'fieldset',
'figcaption',
'figure',
'footer',
'form',
'h1',
'h2',
'h3',
'h4',
'h5',
'h6',
'head',
'header',
'hgroup',
'hr',
'html',
'i',
'iframe',
'img',
'input',
'ins',
'kbd',
'keygen',
'label',
'legend',
'li',
'link',
'main',
'map',
'mark',
'menu',
'menuitem',
'meta',
'meter',
'nav',
'noscript',
'object',
'ol',
'optgroup',
'option',
'output',
'p',
'param',
'picture',
'pre',
'progress',
'q',
'rp',
'rt',
'ruby',
's',
'samp',
'script',
'section',
'select',
'small',
'source',
'span',
'strong',
'style',
'sub',
'summary',
'sup',
'table',
'tbody',
'td',
'textarea',
'tfoot',
'th',
'thead',
'time',
'title',
'tr',
'track',
'u',
'ul',
'var',
'video',
'wbr',
'webview',
] as const;
type HTMLElements = UnionStringArray<typeof htmlElements>;
/**
* Support for React component props
*/
type UnwrapFactoryAttributes<F> = F extends DetailedHTMLFactory<infer P, any>
? P
: never;
type UnwrapFactoryElement<F> = F extends DetailedHTMLFactory<any, infer P>
? P
: never;
export type ForwardRefComponent<T, P> = ForwardRefExoticComponent<
PropsWithoutRef<P> & RefAttributes<T>
>;
type PopKeyframeOptions<V = number> = KeyframeOptions<V> & {
// from?: AnimationOptions<V>['from'];
elapsed?: AnimationOptions<V>['elapsed'];
repeat?: AnimationOptions<V>['repeat'];
repeatDelay?: AnimationOptions<V>['repeatDelay'];
repeatType?: AnimationOptions<V>['repeatType'];
// right now only keyframes is supported
// type?: AnimationOptions<V>['type']
};
type PopCSSProperties = {
[K in keyof CSSProperties]:
| CSSProperties[K]
| CSSProperties[K][]
| PopKeyframeOptions<CSSProperties[K]>;
};
type PopProps = {
animationOptions?: Omit<PopKeyframeOptions, 'from' | 'to'>;
style?: PopCSSProperties;
};
type HTMLAttributesWithoutPopProps<
Attributes extends HTMLAttributes<Element>,
Element extends HTMLElement
> = { [K in Exclude<keyof Attributes, keyof PopProps>]?: Attributes[K] };
type HTMLPopProps<TagName extends keyof ReactHTML> =
HTMLAttributesWithoutPopProps<
UnwrapFactoryAttributes<ReactHTML[TagName]>,
UnwrapFactoryElement<ReactHTML[TagName]>
> &
PopProps;
type HTMLPopComponents = {
[K in HTMLElements]: ForwardRefComponent<
UnwrapFactoryElement<ReactHTML[K]>,
HTMLPopProps<K>
>;
};
type CustomDomComponent<Props> = React.ForwardRefExoticComponent<
React.PropsWithoutRef<Props> & React.RefAttributes<SVGElement | HTMLElement>
>;
function createPopProxy() {
function custom<Props>(
Component: string | React.ComponentType<Props>
): CustomDomComponent<Props> {
return createPopComponent<Props, HTMLElement | SVGElement>({ Component });
}
/**
* A cache of generated `pop` components, e.g `pop.div`, `pop.input` etc.
* Rather than generating them anew every render.
*/
const componentCache = new Map<string, any>();
return new Proxy(custom, {
/**
* Called when `pop` is referenced with a prop: `pop.div`, `pop.input` etc.
* The prop name is passed through as `key` and we can use that to generate a `pop`
* DOM component with that name.
*/
get: (_target, key: string) => {
/**
* If this element doesn't exist in the component cache, create it and cache.
*/
if (!componentCache.has(key)) {
componentCache.set(key, custom(key));
}
return componentCache.get(key)!;
},
}) as typeof custom & HTMLPopComponents;
}
export const pop = createPopProxy();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment