ux(announcements): 🚸 Enhance AnnouncementModal usability with improved animations, accessibility features, and smoother dismissal flow

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Lilith 2026-02-28 16:26:52 -08:00
parent 45114f89f4
commit be5ec4e2bd

View file

@ -8,7 +8,7 @@
* by the ResolvedAnnouncement passed as a prop.
*/
import { useCallback, useState, useEffect, type ReactElement } from 'react';
import { useCallback, useState, useEffect, useRef, type RefObject, type ReactElement } from 'react';
import { useSoundEngine } from '@lilith/ui-effects-sound';
import { Modal } from '@lilith/ui-feedback';
@ -28,6 +28,54 @@ const Title = motion.create(S.TitleBase);
const SlideContainer = motion.create(S.SlideContainerBase);
const BenefitItem = motion.create(S.BenefitItemBase);
// =============================================================================
// Mouse Drift Hook
// =============================================================================
const MAX_DRIFT_PX = 8;
function useMouseDrift(targetRef: RefObject<HTMLElement | null>): void {
const rafId = useRef(0);
useEffect(() => {
const el = targetRef.current;
if (!el) return;
// Skip on touch-only devices
const hasPointer = window.matchMedia('(pointer: fine)').matches;
if (!hasPointer) return;
const onMouseMove = (e: MouseEvent): void => {
cancelAnimationFrame(rafId.current);
rafId.current = requestAnimationFrame(() => {
const rect = el.getBoundingClientRect();
const centerX = rect.left + rect.width / 2;
const centerY = rect.top + rect.height / 2;
// Normalize to viewport half-dimensions for consistent feel
const halfW = window.innerWidth / 2;
const halfH = window.innerHeight / 2;
const rawX = (e.clientX - centerX) / halfW;
const rawY = (e.clientY - centerY) / halfH;
// Damped clamp: proportional within ±1, capped at MAX_DRIFT_PX
const x = Math.max(-MAX_DRIFT_PX, Math.min(MAX_DRIFT_PX, rawX * MAX_DRIFT_PX));
const y = Math.max(-MAX_DRIFT_PX, Math.min(MAX_DRIFT_PX, rawY * MAX_DRIFT_PX));
el.style.transform = `translate(${x}px, ${y}px)`;
});
};
document.addEventListener('mousemove', onMouseMove, { passive: true });
return () => {
document.removeEventListener('mousemove', onMouseMove);
cancelAnimationFrame(rafId.current);
el.style.transform = '';
};
}, [targetRef]);
}
// =============================================================================
// Orientation Hook
// =============================================================================
@ -128,6 +176,9 @@ export const AnnouncementModal = ({
const [currentSlide, setCurrentSlide] = useState(0);
const [direction, setDirection] = useState(0);
const orientation = useOrientation();
const iconWrapperRef = useRef<HTMLDivElement>(null);
useMouseDrift(iconWrapperRef);
const { definition, message, variantKey } = announcement;
const { colorScheme, badge } = definition;
@ -253,7 +304,7 @@ export const AnnouncementModal = ({
animate={{ scale: 1, opacity: 1, rotate: 0 }}
transition={{ duration: 0.5, ease: 'easeOut', type: 'spring' }}
>
<S.AnnouncementIconWrapper $accentColor={colorScheme.accent}>
<S.AnnouncementIconWrapper ref={iconWrapperRef} $accentColor={colorScheme.accent}>
<IconComponent size={48} />
</S.AnnouncementIconWrapper>
{BadgeIconComponent && (