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 // NOTE: Must be run before cancellation below
64 const keyframes = applyKeyframe(element, options.to || {});
65
66 const prevAnimation = animations.get(element);
67 if (prevAnimation) prevAnimation.cancel();
68
69 const animation = element.animate(keyframes, effect);
70
71 animation.playbackRate = 1.000001;
72 animation.currentTime = 0.1;
73
74 let animating = false;
75 const media = matchMedia('(prefers-reduced-motion: reduce)');
76 const computed = getComputedStyle(element);
77 if (!media.matches) {
78 for (const propName in keyframes[1]) {
79 const value = /^--/.test(propName)
80 ? element.style.getPropertyValue(propName)
81 : computed[propName];
82 if (value !== keyframes[0][propName]) {
83 animating = true;
84 break;
85 }
86 }
87 }
88
89 if (!animating) {
90 animations.delete(element);
91 animation.cancel();
92 return;
93 }
94
95 const promise = new Promise<unknown>((resolve, reject) => {
96 animations.set(element, animation);
97 animation.addEventListener('cancel', reject);
98 animation.addEventListener('finish', resolve);
99 });
100
101 if (options.final) {
102 return promise.then(() => {
103 applyKeyframe(element, options.final!);
104 });
105 }
106
107 return promise;
108};
109
110export type Animate = (options: TransitionOptions) => Promise<void> | void;
111
112export function useStyleTransition<T extends HTMLElement>(
113 ref: Ref<T>,
114 options?: TransitionOptions
115): [boolean, Animate] {
116 if (!options) options = {};
117
118 const style = options.to || {};
119 const [state, setState] = useState<[boolean, Style]>([false, style]);
120 if (JSON.stringify(style) !== JSON.stringify(state[1])) {
121 setState([true, style]);
122 }
123
124 const animateTo = useCallback(
125 (options: TransitionOptions) => {
126 const updateAnimating = (animating: boolean) => {
127 setState(state =>
128 state[0] !== animating ? [animating, state[1]] : state
129 );
130 };
131
132 const animation = animate(ref.current!, options);
133 updateAnimating(!!animation);
134 if (animation) {
135 return animation
136 .then(() => {
137 updateAnimating(false);
138 })
139 .catch(() => {});
140 }
141 },
142 [ref]
143 );
144
145 useLayoutEffect(() => {
146 animateTo(options!);
147 }, [animateTo, state[1]]);
148
149 return [state[0], animateTo];
150}