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}