feat(P5-4): animated chat backgrounds + pause toggle

5 new CSS-only animated backgrounds in the chat background picker:
- Digital Rain: two-layer vertical stripe scroll with parallax (wide
  stripes at 8s, narrow at 4s via single keyframe with split positions)
- Star Drift: three-layer radial-gradient star field drifting diagonally
- Grid Pulse: neon grid lines that expand/contract (backgroundSize keyframe)
- Aurora Flow: large radial gradient bands sweeping across 200% canvas
- Fireflies: three layers of glowing dots drifting across the viewport

All use vanilla-extract keyframes (GPU-composited transforms/positions,
no canvas, no JS timers). prefers-reduced-motion is respected in
getChatBg() by stripping the animation property at call time. A "Pause
Background Animations" toggle in Settings → Appearance provides an
in-app override for the same purpose.

BG labels de-duplicated ("Digital Rain", "Star Drift", "Aurora Flow")
to avoid the duplicate "Stars" and "Aurora" entries that had appeared.
LIGHT anim-fireflies background corrected from near-black #0a0a10 to
warm white #fffdf0. Four unused keyframe exports removed from
Animations.css.ts.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-05 11:46:16 -04:00
parent bf75f4657d
commit 8f0e05ffef
6 changed files with 224 additions and 8 deletions
+154 -2
View File
@@ -1,5 +1,12 @@
import { CSSProperties } from 'react';
import { ChatBackground } from '../../state/settings';
import {
animRainKeyframe,
animStarsDriftKeyframe,
animGridPulseKeyframe,
animAuroraKeyframe,
animFirefliesKeyframe,
} from '../../styles/Animations.css';
export const BG_OPTIONS: { value: ChatBackground; label: string }[] = [
{ value: 'none', label: 'None' },
@@ -19,6 +26,11 @@ export const BG_OPTIONS: { value: ChatBackground; label: string }[] = [
{ value: 'waves', label: 'Waves' },
{ value: 'neon', label: 'Neon Grid' },
{ value: 'aurora', label: 'Aurora' },
{ value: 'anim-rain', label: 'Digital Rain' },
{ value: 'anim-stars', label: 'Star Drift' },
{ value: 'anim-pulse', label: 'Grid Pulse' },
{ value: 'anim-aurora', label: 'Aurora Flow' },
{ value: 'anim-fireflies', label: 'Fireflies' },
];
const DARK: Record<ChatBackground, CSSProperties> = {
@@ -184,6 +196,71 @@ const DARK: Record<ChatBackground, CSSProperties> = {
'radial-gradient(ellipse at 60% 90%, rgba(0,212,255,0.06) 0%, transparent 50%)',
].join(','),
},
// Animated: Matrix digital rain — scrolling vertical green stripes
'anim-rain': {
backgroundColor: '#010804',
backgroundImage: [
'repeating-linear-gradient(180deg, rgba(0,255,136,0.13) 0px, rgba(0,255,136,0.13) 1px, transparent 1px, transparent 20px)',
'repeating-linear-gradient(180deg, rgba(0,255,136,0.07) 0px, rgba(0,255,136,0.07) 1px, transparent 1px, transparent 8px)',
].join(','),
backgroundSize: '40px 200px, 12px 200px',
backgroundPosition: '0 0, 0 0',
animation: `${animRainKeyframe} 8s linear infinite`,
},
// Animated: drifting star field — three layers at different speeds
'anim-stars': {
backgroundColor: '#050510',
backgroundImage: [
'radial-gradient(circle, rgba(255,255,255,0.85) 1px, transparent 1px)',
'radial-gradient(circle, rgba(200,220,255,0.55) 1px, transparent 1px)',
'radial-gradient(circle, rgba(180,200,255,0.3) 1px, transparent 1px)',
].join(','),
backgroundSize: '130px 130px, 190px 190px, 260px 260px',
backgroundPosition: '0 0, 65px 32px, 32px 97px',
animation: `${animStarsDriftKeyframe} 25s linear infinite`,
},
// Animated: neon grid pulse — grid lines that expand/contract
'anim-pulse': {
backgroundColor: '#030508',
backgroundImage: [
'linear-gradient(rgba(255,107,0,0.12) 1px, transparent 1px)',
'linear-gradient(90deg, rgba(255,107,0,0.12) 1px, transparent 1px)',
'linear-gradient(rgba(0,212,255,0.06) 1px, transparent 1px)',
'linear-gradient(90deg, rgba(0,212,255,0.06) 1px, transparent 1px)',
].join(','),
backgroundSize: '60px 60px, 60px 60px, 12px 12px, 12px 12px',
animation: `${animGridPulseKeyframe} 4s ease-in-out infinite`,
},
// Animated: aurora borealis — slowly drifting gradient bands
'anim-aurora': {
backgroundColor: '#020a10',
backgroundImage: [
'radial-gradient(ellipse at 20% 30%, rgba(0,255,136,0.10) 0%, transparent 55%)',
'radial-gradient(ellipse at 80% 70%, rgba(0,100,255,0.10) 0%, transparent 55%)',
'radial-gradient(ellipse at 50% 10%, rgba(191,95,255,0.08) 0%, transparent 50%)',
'radial-gradient(ellipse at 60% 90%, rgba(0,212,255,0.08) 0%, transparent 50%)',
].join(','),
backgroundSize: '200% 200%',
backgroundPosition: '0% 0%',
animation: `${animAuroraKeyframe} 20s ease-in-out infinite`,
},
// Animated: fireflies — three layers of glowing dots at different speeds
'anim-fireflies': {
backgroundColor: '#030508',
backgroundImage: [
'radial-gradient(circle, rgba(255,220,50,0.55) 1.5px, rgba(255,160,0,0.15) 3px, transparent 4px)',
'radial-gradient(circle, rgba(255,200,30,0.45) 1px, rgba(255,140,0,0.12) 2.5px, transparent 3.5px)',
'radial-gradient(circle, rgba(255,240,100,0.35) 1px, transparent 2px)',
].join(','),
backgroundSize: '200px 200px, 280px 280px, 160px 160px',
backgroundPosition: '0 0, 120px 80px, 60px 140px',
animation: `${animFirefliesKeyframe} 15s linear infinite`,
},
};
const LIGHT: Record<ChatBackground, CSSProperties> = {
@@ -340,7 +417,82 @@ const LIGHT: Record<ChatBackground, CSSProperties> = {
'radial-gradient(ellipse at 60% 90%, rgba(0,160,200,0.07) 0%, transparent 50%)',
].join(','),
},
// Animated light variants
'anim-rain': {
backgroundColor: '#f0fff4',
backgroundImage: [
'repeating-linear-gradient(180deg, rgba(0,160,80,0.14) 0px, rgba(0,160,80,0.14) 1px, transparent 1px, transparent 20px)',
'repeating-linear-gradient(180deg, rgba(0,160,80,0.07) 0px, rgba(0,160,80,0.07) 1px, transparent 1px, transparent 8px)',
].join(','),
backgroundSize: '40px 200px, 12px 200px',
backgroundPosition: '0 0, 0 0',
animation: `${animRainKeyframe} 8s linear infinite`,
},
'anim-stars': {
backgroundColor: '#f5f5ff',
backgroundImage: [
'radial-gradient(circle, rgba(60,60,160,0.50) 1px, transparent 1px)',
'radial-gradient(circle, rgba(80,80,180,0.35) 1px, transparent 1px)',
'radial-gradient(circle, rgba(100,100,200,0.20) 1px, transparent 1px)',
].join(','),
backgroundSize: '130px 130px, 190px 190px, 260px 260px',
backgroundPosition: '0 0, 65px 32px, 32px 97px',
animation: `${animStarsDriftKeyframe} 25s linear infinite`,
},
'anim-pulse': {
backgroundColor: '#ffffff',
backgroundImage: [
'linear-gradient(rgba(0,98,184,0.14) 1px, transparent 1px)',
'linear-gradient(90deg, rgba(0,98,184,0.14) 1px, transparent 1px)',
'linear-gradient(rgba(0,98,184,0.06) 1px, transparent 1px)',
'linear-gradient(90deg, rgba(0,98,184,0.06) 1px, transparent 1px)',
].join(','),
backgroundSize: '60px 60px, 60px 60px, 12px 12px, 12px 12px',
animation: `${animGridPulseKeyframe} 4s ease-in-out infinite`,
},
'anim-aurora': {
backgroundColor: '#f0f8f4',
backgroundImage: [
'radial-gradient(ellipse at 20% 30%, rgba(0,160,80,0.12) 0%, transparent 55%)',
'radial-gradient(ellipse at 80% 70%, rgba(0,80,200,0.12) 0%, transparent 55%)',
'radial-gradient(ellipse at 50% 10%, rgba(140,60,220,0.09) 0%, transparent 50%)',
'radial-gradient(ellipse at 60% 90%, rgba(0,160,200,0.09) 0%, transparent 50%)',
].join(','),
backgroundSize: '200% 200%',
backgroundPosition: '0% 0%',
animation: `${animAuroraKeyframe} 20s ease-in-out infinite`,
},
'anim-fireflies': {
backgroundColor: '#fffdf0',
backgroundImage: [
'radial-gradient(circle, rgba(180,120,0,0.55) 1.5px, rgba(160,90,0,0.15) 3px, transparent 4px)',
'radial-gradient(circle, rgba(160,100,0,0.45) 1px, rgba(140,80,0,0.12) 2.5px, transparent 3.5px)',
'radial-gradient(circle, rgba(200,140,0,0.35) 1px, transparent 2px)',
].join(','),
backgroundSize: '200px 200px, 280px 280px, 160px 160px',
backgroundPosition: '0 0, 120px 80px, 60px 140px',
animation: `${animFirefliesKeyframe} 15s linear infinite`,
},
};
export const getChatBg = (bg: ChatBackground, isDark: boolean): CSSProperties =>
isDark ? DARK[bg] : LIGHT[bg];
export const getChatBg = (
bg: ChatBackground,
isDark: boolean,
pauseAnimations?: boolean,
): CSSProperties => {
const style = isDark ? DARK[bg] : LIGHT[bg];
const reducedMotion =
typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if ((pauseAnimations || reducedMotion) && style.animation) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { animation: _anim, ...rest } = style;
return rest;
}
return style;
};
+7 -2
View File
@@ -61,6 +61,7 @@ export function RoomView({ eventId }: { eventId?: string }) {
const roomViewRef = useRef<HTMLDivElement>(null) as React.RefObject<HTMLDivElement>;
const [chatBackground] = useSetting(settingsAtom, 'chatBackground');
const [lotusTerminal] = useSetting(settingsAtom, 'lotusTerminal');
const [pauseAnimations] = useSetting(settingsAtom, 'pauseAnimations');
const theme = useTheme();
const isDark = theme.kind === ThemeKind.Dark;
@@ -98,8 +99,12 @@ export function RoomView({ eventId }: { eventId?: string }) {
const chatBgStyle = useMemo(
() =>
getChatBg(lotusTerminal && chatBackground === 'none' ? 'tactical' : chatBackground, isDark),
[chatBackground, lotusTerminal, isDark],
getChatBg(
lotusTerminal && chatBackground === 'none' ? 'tactical' : chatBackground,
isDark,
pauseAnimations,
),
[chatBackground, lotusTerminal, isDark, pauseAnimations],
);
return (
+11 -1
View File
@@ -331,6 +331,7 @@ function Appearance() {
settingsAtom,
'glassmorphismSidebar',
);
const [pauseAnimations, setPauseAnimations] = useSetting(settingsAtom, 'pauseAnimations');
return (
<Box direction="Column" gap="100">
@@ -390,6 +391,14 @@ function Appearance() {
</Box>
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Pause Background Animations"
description="Stop background animation to reduce motion or improve performance."
after={<Switch variant="Primary" value={pauseAnimations} onChange={setPauseAnimations} />}
/>
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Show Profile on Every Message"
@@ -1056,6 +1065,7 @@ function Calls() {
function ChatBgGrid() {
const [chatBackground, setChatBackground] = useSetting(settingsAtom, 'chatBackground');
const [pauseAnimations] = useSetting(settingsAtom, 'pauseAnimations');
const theme = useTheme();
const isDark = theme.kind === ThemeKind.Dark;
@@ -1080,7 +1090,7 @@ function ChatBgGrid() {
: '2px solid rgba(128,128,128,0.25)',
padding: 0,
overflow: 'hidden',
...getChatBg(opt.value as ChatBackground, isDark),
...getChatBg(opt.value as ChatBackground, isDark, pauseAnimations),
}}
/>
<Text
+6 -2
View File
@@ -31,6 +31,7 @@ export function SidebarNav() {
const [glassmorphismSidebar] = useSetting(settingsAtom, 'glassmorphismSidebar');
const [chatBackground] = useSetting(settingsAtom, 'chatBackground');
const [lotusTerminal] = useSetting(settingsAtom, 'lotusTerminal');
const [pauseAnimations] = useSetting(settingsAtom, 'pauseAnimations');
const theme = useTheme();
const isDark = theme.kind === ThemeKind.Dark;
@@ -44,21 +45,24 @@ export function SidebarNav() {
style.removeProperty('background-color');
style.removeProperty('background-size');
style.removeProperty('background-position');
style.removeProperty('animation');
return;
}
const effectiveBg = lotusTerminal && chatBackground === 'none' ? 'tactical' : chatBackground;
const bgStyle = getChatBg(effectiveBg, isDark);
const bgStyle = getChatBg(effectiveBg, isDark, pauseAnimations);
style.backgroundImage = (bgStyle.backgroundImage as string | undefined) ?? '';
style.backgroundColor = (bgStyle.backgroundColor as string | undefined) ?? '';
style.backgroundSize = (bgStyle.backgroundSize as string | undefined) ?? '';
style.backgroundPosition = (bgStyle.backgroundPosition as string | undefined) ?? '';
style.animation = (bgStyle.animation as string | undefined) ?? '';
return () => {
style.removeProperty('background-image');
style.removeProperty('background-color');
style.removeProperty('background-size');
style.removeProperty('background-position');
style.removeProperty('animation');
};
}, [glassmorphismSidebar, chatBackground, lotusTerminal, isDark]);
}, [glassmorphismSidebar, chatBackground, lotusTerminal, isDark, pauseAnimations]);
return (
<Sidebar className={classNames(glassmorphismSidebar && SidebarGlass)}>
+10 -1
View File
@@ -26,7 +26,12 @@ export type ChatBackground =
| 'hexgrid'
| 'waves'
| 'neon'
| 'aurora';
| 'aurora'
| 'anim-rain'
| 'anim-stars'
| 'anim-pulse'
| 'anim-aurora'
| 'anim-fireflies';
export enum MessageLayout {
Modern = 0,
Compact = 1,
@@ -94,6 +99,8 @@ export interface Settings {
deafenKey: string;
warnOnUnverifiedDevices: boolean;
pauseAnimations: boolean;
}
const defaultSettings: Settings = {
@@ -157,6 +164,8 @@ const defaultSettings: Settings = {
deafenKey: 'KeyM',
warnOnUnverifiedDevices: false,
pauseAnimations: false,
};
export const getSettings = (): Settings => {
+36
View File
@@ -69,3 +69,39 @@ export const MsgAppearClass = style({
},
},
});
// Animated chat background keyframes
// Animated chat background keyframes
/** Matrix rain — two stripe layers scroll at different speeds for parallax depth */
export const animRainKeyframe = keyframes({
from: { backgroundPosition: '0 0, 0 0' },
to: { backgroundPosition: '0 200px, 0 100px' },
});
/** Drifting stars — three layers drift diagonally */
export const animStarsDriftKeyframe = keyframes({
from: { backgroundPosition: '0 0, 65px 32px, 32px 97px' },
to: { backgroundPosition: '130px 130px, 195px 162px, 162px 227px' },
});
/** Grid pulse — expands/contracts backgroundSize slightly */
export const animGridPulseKeyframe = keyframes({
'0%': { backgroundSize: '60px 60px, 60px 60px, 12px 12px, 12px 12px' },
'50%': { backgroundSize: '66px 66px, 66px 66px, 13px 13px, 13px 13px' },
'100%': { backgroundSize: '60px 60px, 60px 60px, 12px 12px, 12px 12px' },
});
/** Aurora — sweeps backgroundPosition in large cycle */
export const animAuroraKeyframe = keyframes({
'0%': { backgroundPosition: '0% 0%' },
'50%': { backgroundPosition: '-50% -25%' },
'100%': { backgroundPosition: '0% 0%' },
});
/** Fireflies — three layers of glowing dots drift diagonally */
export const animFirefliesKeyframe = keyframes({
from: { backgroundPosition: '0 0, 120px 80px, 60px 140px' },
to: { backgroundPosition: '200px 150px, 320px 230px, 260px 290px' },
});