Avatar decorations are animated APNGs and were the only motion feature not gated on prefers-reduced-motion (chat backgrounds / seasonal overlays all suppress motion under it). Since there's no static-frame asset to freeze to, render just the avatar (no decoration overlay) when the user prefers reduced motion — the only motion-respecting option. Users without the preference are unaffected; live OS-toggle is reactive via useReducedMotion. Also relieves the mobile perf drain of dozens of live APNGs in scrolling lists. Reviewed: correct a11y behavior, hooks-safe, no layout dependency on the overlay. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
72 lines
2.0 KiB
TypeScript
72 lines
2.0 KiB
TypeScript
import React from 'react';
|
|
import { useAvatarDecoration } from '../../hooks/useAvatarDecoration';
|
|
import { useReducedMotion } from '../../hooks/useReducedMotion';
|
|
import { decorationUrl } from '../../features/lotus/avatarDecorations';
|
|
|
|
const DEFAULT_INSET = 8;
|
|
|
|
type AvatarDecorationProps = {
|
|
userId: string;
|
|
children: React.ReactNode;
|
|
inset?: number;
|
|
};
|
|
|
|
export function AvatarDecoration({
|
|
userId,
|
|
children,
|
|
inset = DEFAULT_INSET,
|
|
}: AvatarDecorationProps) {
|
|
const slug = useAvatarDecoration(userId);
|
|
const reducedMotion = useReducedMotion();
|
|
|
|
// Decorations are animated APNGs with no static asset to freeze to, so honor
|
|
// prefers-reduced-motion by not rendering the animation at all (consistent
|
|
// with the rest of the theming stack — chat backgrounds / seasonal overlays —
|
|
// which all suppress motion under this preference; also avoids dozens of live
|
|
// APNGs animating in scrolling mobile lists).
|
|
if (!slug || reducedMotion) {
|
|
return <>{children}</>;
|
|
}
|
|
|
|
return (
|
|
<div
|
|
style={{
|
|
position: 'relative',
|
|
display: 'inline-flex',
|
|
flexShrink: 0,
|
|
}}
|
|
>
|
|
{children}
|
|
<img
|
|
// Force a fresh element per slug so a recycled node whose previous slug
|
|
// 404'd (and was hidden in onError) can't leak `display:none` onto a
|
|
// valid decoration.
|
|
key={slug}
|
|
src={decorationUrl(slug)}
|
|
style={{
|
|
position: 'absolute',
|
|
top: -inset,
|
|
left: -inset,
|
|
right: -inset,
|
|
bottom: -inset,
|
|
width: `calc(100% + ${inset * 2}px)`,
|
|
height: `calc(100% + ${inset * 2}px)`,
|
|
pointerEvents: 'none',
|
|
zIndex: 10,
|
|
objectFit: 'contain',
|
|
}}
|
|
alt=""
|
|
aria-hidden="true"
|
|
loading="lazy"
|
|
decoding="async"
|
|
onLoad={(e) => {
|
|
(e.currentTarget as HTMLImageElement).style.removeProperty('display');
|
|
}}
|
|
onError={(e) => {
|
|
(e.currentTarget as HTMLImageElement).style.display = 'none';
|
|
}}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|