Mirror: React hooks for accessible, common web interactions. UI super powers without the UI.
1import type { Style, Ref } from './types';
2import { useState, useCallback } from 'react';
3import { useLayoutEffect } from './utils/react';
4
5const animations = new WeakMap<HTMLElement, Animation>();
6
7export interface TransitionOptions {
8 style?: Style | null;
9 duration?: number | string;
10 easing?: string | [number, number, number, number];
11}
12
13const animate = (element: HTMLElement, options: TransitionOptions) => {
14 const style = options.style || {};
15 const computed = getComputedStyle(element);
16 const from: Keyframe = {};
17 const to: Keyframe = {};
18
19 for (const propName in style) {
20 let value: string = style[propName];
21 if (typeof value === 'number' && propName !== 'opacity') {
22 (value as string) += 'px';
23 }
24
25 let key: string;
26 if (/^--/.test(propName)) {
27 key = propName;
28 from[key] = element.style.getPropertyValue(propName);
29 element.style.setProperty(key, (to[key] = value));
30 } else {
31 if (propName === 'transform') {
32 key = propName;
33 value =
34 ('' + value || '').replace(/\w+\((?:0\w*\s*)+\)\s*/g, '') || 'none';
35 } else {
36 key = propName.replace(/[A-Z]/g, '-$&').toLowerCase();
37 }
38
39 from[key] = computed[key];
40 element.style[key] = to[key] = value;
41 }
42 }
43
44 const effect: KeyframeEffectOptions = {
45 duration:
46 typeof options.duration === 'number'
47 ? options.duration * 1000
48 : options.duration || 1000,
49 easing: Array.isArray(options.easing)
50 ? `cubic-bezier(${options.easing.join(', ')})`
51 : options.easing || 'ease',
52 };
53
54 const prevAnimation = animations.get(element);
55 if (prevAnimation) prevAnimation.cancel();
56
57 const animation = element.animate([from, to], effect);
58 animation.playbackRate = 1.000001;
59 animation.currentTime = 0.1;
60
61 let animating = false;
62 for (const propName in from) {
63 const value = /^--/.test(propName)
64 ? element.style.getPropertyValue(propName)
65 : computed[propName];
66 if (value !== from[propName]) {
67 animating = true;
68 break;
69 }
70 }
71
72 if (!animating) {
73 animations.delete(element);
74 animation.cancel();
75 return;
76 }
77
78 return new Promise<unknown>((resolve, reject) => {
79 animations.set(element, animation);
80 animation.addEventListener('cancel', reject);
81 animation.addEventListener('finish', resolve);
82 });
83};
84
85export function useStyleTransition<T extends HTMLElement>(
86 ref: Ref<T>,
87 options?: TransitionOptions
88): [boolean, (options: TransitionOptions) => Promise<void>] {
89 if (!options) options = {};
90
91 const style = options.style || {};
92 const [state, setState] = useState<[boolean, Style]>([false, style]);
93 if (JSON.stringify(style) !== JSON.stringify(state[1])) {
94 setState([true, style]);
95 }
96
97 const animateTo = useCallback(
98 (options: TransitionOptions) => {
99 const updateAnimating = (animating: boolean) => {
100 setState(state =>
101 state[0] !== animating ? [animating, state[1]] : state
102 );
103 };
104
105 const animation = animate(ref.current!, options);
106 if (animation) {
107 updateAnimating(true);
108 return animation
109 .then(() => {
110 updateAnimating(false);
111 })
112 .catch(() => {});
113 } else {
114 updateAnimating(false);
115 return Promise.resolve();
116 }
117 },
118 [ref]
119 );
120
121 useLayoutEffect(() => {
122 animateTo(options!);
123 }, [animateTo, state[1]]);
124
125 return [state[0], animateTo];
126}