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 function useStyleTransition<T extends HTMLElement>( 111 ref: Ref<T>, 112 options?: TransitionOptions 113): [boolean, (options: TransitionOptions) => Promise<void>] { 114 if (!options) options = {}; 115 116 const style = options.to || {}; 117 const [state, setState] = useState<[boolean, Style]>([false, style]); 118 if (JSON.stringify(style) !== JSON.stringify(state[1])) { 119 setState([true, style]); 120 } 121 122 const animateTo = useCallback( 123 (options: TransitionOptions) => { 124 const updateAnimating = (animating: boolean) => { 125 setState(state => 126 state[0] !== animating ? [animating, state[1]] : state 127 ); 128 }; 129 130 const animation = animate(ref.current!, options); 131 if (animation) { 132 updateAnimating(true); 133 return animation 134 .then(() => { 135 updateAnimating(false); 136 }) 137 .catch(() => {}); 138 } else { 139 updateAnimating(false); 140 return Promise.resolve(); 141 } 142 }, 143 [ref] 144 ); 145 146 useLayoutEffect(() => { 147 animateTo(options!); 148 }, [animateTo, state[1]]); 149 150 return [state[0], animateTo]; 151}