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}