Mirror: React hooks for accessible, common web interactions. UI super powers without the UI.
at main 4.0 kB view raw
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}