Essential utility functions and React hooks for building accessible React Aria UI components
—
Quality
Pending
Does it follow best practices?
Impact
Pending
No eval scenarios have been run
Enter/exit animation management with CSS integration and transition coordination for React components.
Hook for managing enter animations when components mount or become visible.
/**
* Manages enter animations for components
* @param ref - RefObject to animated element
* @param isReady - Whether animation should start (default: true)
* @returns Boolean indicating if element is in entering state
*/
function useEnterAnimation(ref: RefObject<HTMLElement>, isReady?: boolean): boolean;Usage Examples:
import { useEnterAnimation } from "@react-aria/utils";
function FadeInComponent({ children, show = true }) {
const elementRef = useRef<HTMLDivElement>(null);
const isEntering = useEnterAnimation(elementRef, show);
return (
<div
ref={elementRef}
className={`fade-component ${isEntering ? 'entering' : 'entered'}`}
style={{
opacity: isEntering ? 0 : 1,
transition: 'opacity 300ms ease-in-out'
}}
>
{children}
</div>
);
}
// CSS-based keyframe animation
function SlideInComponent({ children, show = true }) {
const elementRef = useRef<HTMLDivElement>(null);
const isEntering = useEnterAnimation(elementRef, show);
return (
<div
ref={elementRef}
className={`slide-component ${isEntering ? 'slide-enter' : 'slide-entered'}`}
>
{children}
</div>
);
}
// CSS for slideInComponent
/*
.slide-component {
transform: translateX(-100%);
transition: transform 400ms cubic-bezier(0.4, 0, 0.2, 1);
}
.slide-component.slide-entered {
transform: translateX(0);
}
.slide-component.slide-enter {
animation: slideIn 400ms cubic-bezier(0.4, 0, 0.2, 1) forwards;
}
@keyframes slideIn {
from { transform: translateX(-100%); }
to { transform: translateX(0); }
}
*/Hook for managing exit animations before components unmount or become hidden.
/**
* Manages exit animations before unmounting
* @param ref - RefObject to animated element
* @param isOpen - Whether component should be open/visible
* @returns Boolean indicating if element is in exiting state
*/
function useExitAnimation(ref: RefObject<HTMLElement>, isOpen: boolean): boolean;Usage Examples:
import { useExitAnimation } from "@react-aria/utils";
function Modal({ isOpen, onClose, children }) {
const modalRef = useRef<HTMLDivElement>(null);
const isExiting = useExitAnimation(modalRef, isOpen);
// Don't render if closed and not exiting
if (!isOpen && !isExiting) return null;
return (
<div
className={`modal-backdrop ${isExiting ? 'exiting' : ''}`}
style={{
opacity: isExiting ? 0 : 1,
transition: 'opacity 250ms ease-out'
}}
>
<div
ref={modalRef}
className={`modal-content ${isExiting ? 'modal-exit' : 'modal-enter'}`}
style={{
transform: isExiting ? 'scale(0.95) translateY(-10px)' : 'scale(1) translateY(0)',
transition: 'transform 250ms ease-out'
}}
>
<button onClick={onClose}>Close</button>
{children}
</div>
</div>
);
}
// Notification with auto-dismiss animation
function Notification({ message, onDismiss, autoClose = 5000 }) {
const [isOpen, setIsOpen] = useState(true);
const notificationRef = useRef<HTMLDivElement>(null);
const isExiting = useExitAnimation(notificationRef, isOpen);
useEffect(() => {
const timer = setTimeout(() => setIsOpen(false), autoClose);
return () => clearTimeout(timer);
}, [autoClose]);
useEffect(() => {
if (!isOpen && !isExiting) {
onDismiss();
}
}, [isOpen, isExiting, onDismiss]);
if (!isOpen && !isExiting) return null;
return (
<div
ref={notificationRef}
className={`notification ${isExiting ? 'notification-exit' : 'notification-enter'}`}
style={{
transform: isExiting ? 'translateX(100%)' : 'translateX(0)',
opacity: isExiting ? 0 : 1,
transition: 'transform 300ms ease-in-out, opacity 300ms ease-in-out'
}}
>
{message}
<button onClick={() => setIsOpen(false)}>×</button>
</div>
);
}Function for executing callbacks after all CSS transitions complete.
/**
* Executes callback after all CSS transitions complete
* @param fn - Function to execute after transitions
*/
function runAfterTransition(fn: () => void): void;Usage Examples:
import { runAfterTransition } from "@react-aria/utils";
function AnimatedList({ items, onAnimationComplete }) {
const [isAnimating, setIsAnimating] = useState(false);
const animateList = () => {
setIsAnimating(true);
// Start animation by adding CSS class
const listElement = document.querySelector('.animated-list');
listElement?.classList.add('animate');
// Wait for all transitions to complete
runAfterTransition(() => {
setIsAnimating(false);
listElement?.classList.remove('animate');
onAnimationComplete();
});
};
return (
<div>
<button onClick={animateList} disabled={isAnimating}>
{isAnimating ? 'Animating...' : 'Animate List'}
</button>
<ul className="animated-list">
{items.map(item => (
<li key={item.id}>{item.name}</li>
))}
</ul>
</div>
);
}
// Complex animation sequence
function SequentialAnimation({ steps, onComplete }) {
const [currentStep, setCurrentStep] = useState(0);
const elementRefs = useRef<(HTMLDivElement | null)[]>([]);
const animateStep = (stepIndex: number) => {
const element = elementRefs.current[stepIndex];
if (!element) return;
// Add animation class
element.classList.add('step-animate');
// Wait for transition to complete
runAfterTransition(() => {
element.classList.remove('step-animate');
if (stepIndex < steps.length - 1) {
setCurrentStep(stepIndex + 1);
// Animate next step
setTimeout(() => animateStep(stepIndex + 1), 100);
} else {
onComplete();
}
});
};
useEffect(() => {
if (currentStep < steps.length) {
animateStep(currentStep);
}
}, [currentStep, steps.length]);
return (
<div>
{steps.map((step, index) => (
<div
key={index}
ref={el => elementRefs.current[index] = el}
className={`step ${index <= currentStep ? 'active' : ''}`}
>
{step.content}
</div>
))}
</div>
);
}Complex animation scenarios combining enter/exit animations with coordination:
import { useEnterAnimation, useExitAnimation, runAfterTransition } from "@react-aria/utils";
function StaggeredList({ items, isVisible }) {
const listRef = useRef<HTMLUListElement>(null);
const isEntering = useEnterAnimation(listRef, isVisible);
const isExiting = useExitAnimation(listRef, isVisible);
useEffect(() => {
if (!listRef.current) return;
const listItems = Array.from(listRef.current.children) as HTMLElement[];
if (isEntering) {
// Stagger enter animations
listItems.forEach((item, index) => {
item.style.transitionDelay = `${index * 50}ms`;
item.classList.add('item-enter');
});
} else if (isExiting) {
// Reverse stagger for exit
listItems.forEach((item, index) => {
item.style.transitionDelay = `${(listItems.length - index - 1) * 30}ms`;
item.classList.add('item-exit');
});
}
}, [isEntering, isExiting]);
if (!isVisible && !isExiting) return null;
return (
<ul ref={listRef} className="staggered-list">
{items.map(item => (
<li key={item.id}>{item.name}</li>
))}
</ul>
);
}
// Shared element transition
function SharedElementTransition({ fromElement, toElement, onTransitionEnd }) {
useEffect(() => {
if (!fromElement || !toElement) return;
// Get positions
const fromRect = fromElement.getBoundingClientRect();
const toRect = toElement.getBoundingClientRect();
// Create shared element
const sharedElement = fromElement.cloneNode(true) as HTMLElement;
sharedElement.style.position = 'fixed';
sharedElement.style.top = `${fromRect.top}px`;
sharedElement.style.left = `${fromRect.left}px`;
sharedElement.style.width = `${fromRect.width}px`;
sharedElement.style.height = `${fromRect.height}px`;
sharedElement.style.zIndex = '1000';
sharedElement.style.pointerEvents = 'none';
document.body.appendChild(sharedElement);
// Hide original elements
fromElement.style.opacity = '0';
toElement.style.opacity = '0';
// Animate to target position
requestAnimationFrame(() => {
sharedElement.style.transition = 'all 400ms cubic-bezier(0.4, 0, 0.2, 1)';
sharedElement.style.top = `${toRect.top}px`;
sharedElement.style.left = `${toRect.left}px`;
sharedElement.style.width = `${toRect.width}px`;
sharedElement.style.height = `${toRect.height}px`;
});
runAfterTransition(() => {
// Clean up
document.body.removeChild(sharedElement);
fromElement.style.opacity = '';
toElement.style.opacity = '';
onTransitionEnd();
});
}, [fromElement, toElement, onTransitionEnd]);
return null;
}
// Page transition component
function PageTransition({ currentPage, nextPage, direction = 'forward' }) {
const containerRef = useRef<HTMLDivElement>(null);
const [isTransitioning, setIsTransitioning] = useState(false);
const transitionToPage = (newPage: React.ReactNode) => {
if (!containerRef.current || isTransitioning) return;
setIsTransitioning(true);
const container = containerRef.current;
// Add transition classes
container.classList.add('page-transition');
container.classList.add(direction === 'forward' ? 'forward' : 'backward');
runAfterTransition(() => {
// Update page content
setCurrentPage(newPage);
// Remove transition classes
container.classList.remove('page-transition', 'forward', 'backward');
setIsTransitioning(false);
});
};
return (
<div ref={containerRef} className="page-container">
{currentPage}
</div>
);
}CSS classes that work well with these animation hooks:
/* Enter animation classes */
.fade-component {
transition: opacity 300ms ease-in-out;
}
.fade-component.entering {
opacity: 0;
}
.fade-component.entered {
opacity: 1;
}
/* Exit animation classes */
.modal-backdrop {
transition: opacity 250ms ease-out;
}
.modal-backdrop.exiting {
opacity: 0;
}
.modal-content {
transition: transform 250ms ease-out;
}
.modal-content.modal-exit {
transform: scale(0.95) translateY(-10px);
}
/* Staggered list animations */
.staggered-list li {
opacity: 0;
transform: translateY(20px);
transition: opacity 300ms ease-out, transform 300ms ease-out;
}
.staggered-list li.item-enter {
opacity: 1;
transform: translateY(0);
}
.staggered-list li.item-exit {
opacity: 0;
transform: translateY(-10px);
}
/* Page transitions */
.page-container.page-transition.forward {
transform: translateX(-100%);
}
.page-container.page-transition.backward {
transform: translateX(100%);
}Install with Tessl CLI
npx tessl i tessl/npm-react-aria--utils