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 to?: Style | null;
9 final?: Style | null;
10 duration?: number | string;
11 easing?: string | [number, number, number, number];
12}
13
14const applyKeyframe = (
15 element: HTMLElement,
16 style: Style
17): [Keyframe, Keyframe] => {
18 const computed = getComputedStyle(element);
19 const from: Keyframe = {};
20 const to: Keyframe = {};
21
22 for (const propName in style) {
23 let key: string;
24 let value: string =
25 style[propName] +
26 (typeof style[propName] === 'number' && propName !== 'opacity'
27 ? 'px'
28 : '');
29 if (/^--/.test(propName)) {
30 key = propName;
31 from[key] = element.style.getPropertyValue(propName);
32 element.style.setProperty(key, value);
33 } else {
34 if (propName === 'transform') {
35 key = propName;
36 value =
37 ('' + value || '').replace(/\w+\((?:0\w*\s*)+\)\s*/g, '') || 'none';
38 } else {
39 key = propName.replace(/[A-Z]/g, '-$&').toLowerCase();
40 }
41
42 from[key] = computed[key];
43 element.style[key] = value;
44 }
45
46 if (from[key] !== value) to[key] = value;
47 }
48
49 return [from, to];
50};
51
52const animate = (element: HTMLElement, options: TransitionOptions) => {
53 const effect: KeyframeEffectOptions = {
54 duration:
55 typeof options.duration === 'number'
56 ? options.duration * 1000
57 : options.duration || 1000,
58 easing: Array.isArray(options.easing)
59 ? `cubic-bezier(${options.easing.join(', ')})`
60 : options.easing || 'ease',
61 };
62
63 const prevAnimation = animations.get(element);
64 if (prevAnimation) prevAnimation.cancel();
65
66 const keyframes = applyKeyframe(element, options.to || {});
67 const animation = element.animate(keyframes, effect);
68
69 animation.playbackRate = 1.000001;
70 animation.currentTime = 0.1;
71
72 let animating = false;
73 const media = matchMedia('(prefers-reduced-motion: reduce)');
74 const computed = getComputedStyle(element);
75 if (!media.matches) {
76 for (const propName in keyframes[1]) {
77 const value = /^--/.test(propName)
78 ? element.style.getPropertyValue(propName)
79 : computed[propName];
80 if (value !== keyframes[0][propName]) {
81 animating = true;
82 break;
83 }
84 }
85 }
86
87 if (!animating) {
88 animations.delete(element);
89 animation.cancel();
90 return;
91 }
92
93 const promise = new Promise<unknown>((resolve, reject) => {
94 animations.set(element, animation);
95 animation.addEventListener('cancel', reject);
96 animation.addEventListener('finish', resolve);
97 });
98
99 if (options.final) {
100 return promise.then(() => {
101 applyKeyframe(element, options.final!);
102 });
103 }
104
105 return promise;
106};
107
108export function useStyleTransition<T extends HTMLElement>(
109 ref: Ref<T>,
110 options?: TransitionOptions
111): [boolean, (options: TransitionOptions) => Promise<void>] {
112 if (!options) options = {};
113
114 const style = options.to || {};
115 const [state, setState] = useState<[boolean, Style]>([false, style]);
116 if (JSON.stringify(style) !== JSON.stringify(state[1])) {
117 setState([true, style]);
118 }
119
120 const animateTo = useCallback(
121 (options: TransitionOptions) => {
122 const updateAnimating = (animating: boolean) => {
123 setState(state =>
124 state[0] !== animating ? [animating, state[1]] : state
125 );
126 };
127
128 const animation = animate(ref.current!, options);
129 if (animation) {
130 updateAnimating(true);
131 return animation
132 .then(() => {
133 updateAnimating(false);
134 })
135 .catch(() => {});
136 } else {
137 updateAnimating(false);
138 return Promise.resolve();
139 }
140 },
141 [ref]
142 );
143
144 useLayoutEffect(() => {
145 animateTo(options!);
146 }, [animateTo, state[1]]);
147
148 return [state[0], animateTo];
149}