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:
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)}>
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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' },
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user