This article has been machine-translated from Chinese. The translation may contain inaccuracies or awkward phrasing. If in doubt, please refer to the original Chinese version.
This article was written with AI assistance, for record-keeping purposes
Christmas is here, and I wanted to add some holiday atmosphere to the blog. I ended up implementing a pluggable Christmas effects module that includes:
- 3D snowfall (React Three Fiber + GLSL shaders)
- Hanging Christmas lights decoration (CSS animation)
- Avatar Christmas hat (SVG)
- Christmas color scheme (CSS variables)
- Draggable Christmas ornament toggle
Here’s the final result:

If you find it distracting, you can click the toggle to turn it off:

Notice that the snow has two layers — the content container is sandwiched between them. Background snowflakes drift behind the article while foreground snowflakes drift in front, creating a parallax effect that I’m quite pleased with.

For the technical approach, the snowfall uses React Three Fiber to implement a GPU shader-driven 6-layer particle system with mouse parallax effects, striking a balance between visual quality and performance.
Overall Architecture
flowchart TB
subgraph Config[Configuration Layer]
SC[site-config.ts<br/>christmasConfig]
end
subgraph State[State Management]
NS[nanostores<br/>christmasEnabled]
LS[localStorage<br/>Persistence]
end
subgraph Effects[Effect Components]
SF[SnowfallCanvas<br/>GLSL Snow Shader]
CL[ChristmasLights<br/>CSS Lights]
CH[ChristmasHat<br/>SVG Christmas Hat]
CT[christmas-theme.css<br/>Color Scheme]
end
subgraph UI[User Interface]
OT[ChristmasOrnamentToggle<br/>Draggable Toggle]
end
SC --> NS
NS <--> LS
NS --> SF
SC --> CL
OT --> NS
SC --> CH
SC --> CT
All effects are controlled via christmasConfig.enabled, with runtime state managed through nanostores, supporting manual user toggling and persistence to localStorage.
Configuration Structure Design
The Christmas configuration is defined in src/constants/site-config.ts:
type ChristmasConfig = {
enabled: boolean;
features: {
snowfall: boolean; // Snowfall
christmasColorScheme: boolean; // Christmas color scheme
christmasCoverDecoration: boolean; // Cover lights decoration
christmasHat: boolean; // Avatar Christmas hat
};
snowfall: {
speed: number; // Fall speed (1 = normal)
intensity: number; // Snow density (0-1)
mobileIntensity: number; // Mobile density (0-1)
};
};
export const christmasConfig: ChristmasConfig = {
enabled: true,
features: {
snowfall: true,
christmasColorScheme: true,
christmasCoverDecoration: true,
christmasHat: true,
},
snowfall: {
speed: 1,
intensity: 0.6,
mobileIntensity: 0.4,
},
};
This design allows fine-grained control over individual effects, making debugging and selective enabling easy.
State Management: Bridging Astro/React Boundaries
In Astro’s islands architecture, .astro components and React components need to share state. We use nanostores as a lightweight state management solution:
// src/store/christmas.ts
import { atom } from 'nanostores';
const STORAGE_KEY = 'christmas-enabled';
// Runtime toggle state
export const christmasEnabled = atom<boolean>(true);
// Initialize: restore state from localStorage
export function initChristmasState(): void {
if (typeof window === 'undefined') return;
const stored = localStorage.getItem(STORAGE_KEY);
if (stored !== null) {
christmasEnabled.set(stored === 'true');
}
syncChristmasClass(christmasEnabled.get());
}
// Sync html.christmas class (for CSS color scheme switching)
function syncChristmasClass(enabled: boolean): void {
if (typeof document === 'undefined') return;
document.documentElement.classList.toggle('christmas', enabled);
}
// Toggle switch
export function toggleChristmas(): void {
const newValue = !christmasEnabled.get();
christmasEnabled.set(newValue);
localStorage.setItem(STORAGE_KEY, String(newValue));
syncChristmasClass(newValue);
}
// Enable Christmas effects
export function enableChristmas(): void {
christmasEnabled.set(true);
localStorage.setItem(STORAGE_KEY, 'true');
syncChristmasClass(true);
}
// Disable Christmas effects
export function disableChristmas(): void {
christmasEnabled.set(false);
localStorage.setItem(STORAGE_KEY, 'false');
syncChristmasClass(false);
}
In React components, subscribe to the state using useStore from @nanostores/react:
import { useStore } from '@nanostores/react';
import { christmasEnabled } from '@store/christmas';
function SnowfallCanvas() {
const isEnabled = useStore(christmasEnabled);
if (!isEnabled) return null;
// ...render snowflakes
}
In Astro component <script> blocks, use it directly:
import { christmasEnabled, toggleChristmas } from '@store/christmas';
christmasEnabled.subscribe((enabled) => {
// Update UI state
});
Snowfall: React Three Fiber Particle System
I chose React Three Fiber + custom GLSL shaders, referencing the “Just snow” effect on Shadertoy, computing and rendering 6 layers of snowflakes entirely on the GPU.
Particle System Implementation
Using ShaderMaterial for full-screen shader rendering:
// src/components/christmas/SnowParticles.tsx
import { useFrame, useThree } from '@react-three/fiber';
import { useMemo, useRef, type MutableRefObject } from 'react';
import * as THREE from 'three';
const SnowShaderMaterial = {
vertexShader: `
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = vec4(position, 1.0);
}
`,
fragmentShader: `
uniform float uTime;
uniform vec2 uResolution;
uniform float uSpeed;
uniform float uIntensity;
uniform vec2 uMouse; // Mouse parallax offset
uniform int uLayerStart; // Render layer range start
uniform int uLayerEnd; // Render layer range end
varying vec2 vUv;
void main() {
vec2 fragCoord = vUv * uResolution;
float snow = 0.0;
float time = uTime * uSpeed;
// 6 layers of snow, 12 iterations each
for(int k = 0; k < 6; k++) {
if(k < uLayerStart || k > uLayerEnd) continue;
for(int i = 0; i < 12; i++) {
float cellSize = 2.0 + (float(i) * 3.0);
float downSpeed = 0.3 + (sin(time * 0.4 + float(k + i * 20)) + 1.0) * 0.00008;
// Parallax offset: foreground layers offset more
float parallaxFactor = 0.5 + float(k) * 0.1;
vec2 mouseOffset = uMouse * parallaxFactor;
vec2 uv = (fragCoord.xy / uResolution.x) + mouseOffset + vec2(
0.01 * sin((time + float(k * 6185)) * 0.6 + float(i)) * (5.0 / float(i)),
downSpeed * (time + float(k * 1352)) * (1.0 / float(i))
);
vec2 uvStep = (ceil((uv) * cellSize - vec2(0.5, 0.5)) / cellSize);
float x = fract(sin(dot(uvStep.xy, vec2(12.9898 + float(k) * 12.0, 78.233 + float(k) * 315.156))) * 43758.5453 + float(k) * 12.0) - 0.5;
float y = fract(sin(dot(uvStep.xy, vec2(62.2364 + float(k) * 23.0, 94.674 + float(k) * 95.0))) * 62159.8432 + float(k) * 12.0) - 0.5;
float d = 5.0 * distance((uvStep.xy + vec2(x * sin(y), y) * sin(time * 2.5) * 0.7 / cellSize), uv.xy);
float omiVal = fract(sin(dot(uvStep.xy, vec2(32.4691, 94.615))) * 31572.1684);
if(omiVal < 0.08) {
snow += (x + 1.0) * 0.4 * clamp(1.9 - d * (15.0 + (x * 6.3)) * (cellSize / 1.4), 0.0, 1.0);
}
}
}
gl_FragColor = vec4(1.0, 1.0, 1.0, snow * uIntensity);
}
`,
};
interface SnowParticlesProps {
speed?: number;
intensity?: number;
parallaxRef?: MutableRefObject<{ x: number; y: number }>;
layerRange?: [number, number];
}
export function SnowParticles({
speed = 1,
intensity = 0.6,
parallaxRef,
layerRange = [0, 5]
}: SnowParticlesProps) {
const shaderMaterial = useRef<THREE.ShaderMaterial>(null);
const { size } = useThree();
const [layerStart, layerEnd] = layerRange;
const uniforms = useMemo(() => ({
uTime: { value: 0 },
uResolution: { value: new THREE.Vector2(size.width, size.height) },
uSpeed: { value: speed },
uIntensity: { value: intensity },
uMouse: { value: new THREE.Vector2(0, 0) },
uLayerStart: { value: layerStart },
uLayerEnd: { value: layerEnd },
}), [size.width, size.height, speed, intensity, layerStart, layerEnd]);
useFrame((state) => {
if (shaderMaterial.current) {
shaderMaterial.current.uniforms.uTime.value = state.clock.getElapsedTime();
if (parallaxRef) {
shaderMaterial.current.uniforms.uMouse.value.set(parallaxRef.current.x, parallaxRef.current.y);
}
}
});
return (
<mesh>
<planeGeometry args={[2, 2]} />
<shaderMaterial
ref={shaderMaterial}
transparent
depthWrite={false}
blending={THREE.AdditiveBlending}
uniforms={uniforms}
vertexShader={SnowShaderMaterial.vertexShader}
fragmentShader={SnowShaderMaterial.fragmentShader}
/>
</mesh>
);
}
Core concepts:
- Uses
planeGeometryto create a full-screen quad - The Fragment Shader generates snowflakes of different sizes and speeds using 6 nested loops
- Each layer of snowflakes has independent fall speed and wind phase
- The
uLayerStartanduLayerEnduniforms control which layers are rendered (used for foreground/background separation)
Canvas Configuration: Non-blocking Clicks
A key issue is that R3F Canvas intercepts all mouse events by default, preventing clicks on elements below. The solution:
<Canvas
// Use orthographic camera for full-screen shader
orthographic
camera={{ zoom: 1, position: [0, 0, 1] }}
// Disable R3F's event system
eventSource={undefined}
eventPrefix={undefined}
style={{ pointerEvents: 'none' }}
gl={{
antialias: false,
alpha: true,
powerPreference: 'low-power', // Power-saving mode
}}
dpr={[1, 1.5]} // Limit DPR for mobile performance
>
Mouse Parallax Effect
Referencing the “Just snow” effect on Shadertoy, I added parallax offset on mouse movement to give the snowflakes more depth.
Core concept:
- Use Motion’s
useMotionValue+useSpringto track and smooth mouse position - Pass the smoothed values to the shader’s
uMouseuniform - Different layers in the shader use different parallax factors, with foreground layers offsetting more
// SnowfallCanvas.tsx - Mouse tracking
const mouseX = useMotionValue(0);
const mouseY = useMotionValue(0);
const smoothMouseX = useSpring(mouseX, { stiffness: 50, damping: 20 });
const smoothMouseY = useSpring(mouseY, { stiffness: 50, damping: 20 });
useEffect(() => {
if (isMobile) return; // No mouse on mobile
const handleMouseMove = (e: MouseEvent) => {
// Normalize to -0.5 ~ 0.5
mouseX.set(e.clientX / window.innerWidth - 0.5);
mouseY.set(e.clientY / window.innerHeight - 0.5);
};
const handleMouseLeave = () => {
// Return to center when mouse leaves the window
mouseX.set(0);
mouseY.set(0);
};
window.addEventListener('mousemove', handleMouseMove, { passive: true });
document.addEventListener('mouseleave', handleMouseLeave);
return () => {
window.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseleave', handleMouseLeave);
};
}, [isMobile]);
Applying parallax in the shader:
uniform vec2 uMouse;
// In each snow layer's UV calculation
float parallaxFactor = 0.5 + float(k) * 0.1; // k=0~5 → 0.5~1.0
vec2 mouseOffset = uMouse * parallaxFactor;
vec2 uv = (fragCoord.xy / uResolution.x) + mouseOffset + ...;
Since Motion’s spring values update outside React’s render cycle and R3F’s useFrame also runs externally, a bridge component is needed to subscribe to spring changes and store them in a ref:
function SnowParticlesWithParallax({
speed,
intensity,
smoothMouseX,
smoothMouseY,
parallaxStrength,
layerRange,
}: {
speed: number;
intensity: number;
smoothMouseX: MotionValue<number>;
smoothMouseY: MotionValue<number>;
parallaxStrength: number;
layerRange: [number, number];
}) {
const parallaxRef = useRef({ x: 0, y: 0 });
useEffect(() => {
const unsubX = smoothMouseX.on('change', (v) => {
parallaxRef.current.x = v * parallaxStrength;
});
const unsubY = smoothMouseY.on('change', (v) => {
parallaxRef.current.y = v * parallaxStrength;
});
return () => { unsubX(); unsubY(); };
}, [smoothMouseX, smoothMouseY, parallaxStrength]);
return <SnowParticles speed={speed} intensity={intensity} parallaxRef={parallaxRef} layerRange={layerRange} />;
}
Dual-Layer Rendering: Foreground and Background Separation
To give the snowflakes more depth, the 6 layers are split into two groups rendered at different z-index levels:
| Layer | z-index | Shader layers (k) | Effect |
|---|---|---|---|
| Background snow | 1 | k=0-2 | Far, small snowflakes, weak parallax |
| Content | Normal doc flow | - | Article container |
| Foreground snow | 50 | k=3-5 | Near, large snowflakes, strong parallax |
This is implemented by adding a layerRange prop to SnowParticles, with the shader skipping layers outside the range:
uniform int uLayerStart;
uniform int uLayerEnd;
for(int k = 0; k < 6; k++) {
if(k < uLayerStart || k > uLayerEnd) continue;
// ... original snow rendering logic
}
Then render two Canvas instances:
{/* Background snow */}
<SnowfallCanvas zIndex={1} layerRange={[0, 2]} />
{/* Foreground snow */}
<SnowfallCanvas zIndex={50} layerRange={[3, 5]} />
This way, the content container is “sandwiched” between the snowflakes — background snowflakes drift behind the article while foreground snowflakes drift in front, creating excellent depth perception with the parallax effect.
Christmas Lights: CSS Animation
Hanging light decorations are added at the top of the Cover, referencing Toby J’s CodePen implementation, adjusted with Christmas colors (red, green, gold).
Implementation Principle
The lights effect is implemented entirely with CSS:
- Bulbs are created using
border-radius: 50%for an elliptical shape - Lamp bases use
::beforepseudo-elements - Wires are simulated using
::afterpseudo-elements withborder-bottom+border-radiusfor an arc shape - The flickering effect uses
@keyframesanimation to changebackgroundandbox-shadow
---
// src/components/christmas/ChristmasLights.astro
// Generate enough bulbs to cover the maximum screen width
const lightCount = 50;
---
<ul class="lightrope" aria-hidden="true">
{Array.from({ length: lightCount }).map(() => <li />)}
</ul>
<style>
.lightrope {
--globe-width: 12px;
--globe-height: 28px;
--globe-spacing: 40px;
--globe-spread: 3px;
--light-off-opacity: 0.4;
/* Christmas colors */
--light-red: 185, 50, 50;
--light-green: 34, 139, 34;
--light-gold: 218, 165, 32;
position: fixed;
top: 42px;
left: 0;
z-index: 100;
width: 100%;
pointer-events: none;
}
.lightrope li {
display: inline-block;
width: var(--globe-width);
height: var(--globe-height);
margin: calc(var(--globe-spacing) / 2);
border-radius: 50%;
background: rgb(var(--light-red));
box-shadow: 0px 4px 24px 3px rgba(var(--light-red), 1);
animation: flash-red 2s ease-in-out infinite;
}
/* Green bulbs - every other one */
.lightrope li:nth-child(2n + 1) {
background: rgb(var(--light-green));
animation-name: flash-green;
animation-duration: 0.4s;
}
/* Gold bulbs - 2nd of every 4 */
.lightrope li:nth-child(4n + 2) {
background: rgb(var(--light-gold));
animation-name: flash-gold;
animation-duration: 1.1s;
}
/* Lamp base */
.lightrope li::before {
content: '';
position: absolute;
top: -5px;
width: 10px;
height: 9px;
background: hsl(var(--background) / 0.6);
border-radius: 3px;
}
/* Connecting wire */
.lightrope li::after {
content: '';
position: absolute;
top: -14px;
left: 9px;
width: 52px;
height: 18px;
border-bottom: solid hsl(var(--background) / 0.6) 2px;
border-radius: 50%;
}
/* Flicker animation */
@keyframes flash-red {
0%,
100% {
background: rgb(var(--light-red));
box-shadow: 0px 4px 24px 3px rgba(var(--light-red), 1);
}
50% {
background: rgba(var(--light-red), 0.4);
box-shadow: 0px 4px 24px 3px rgba(var(--light-red), 0.2);
}
}
/* Accessibility: reduce motion */
@media (prefers-reduced-motion: reduce) {
.lightrope li {
animation: none;
}
}
</style>
Using in the Cover
// src/components/ui/cover/Cover.astro
+ import { christmasConfig } from '@constants/site-config';
+ import ChristmasLights from '@components/christmas/ChristmasLights.astro';
<div class="relative flex h-[60dvh] max-h-200 overflow-hidden">
+ {christmasConfig.enabled && christmasConfig.features.christmasCoverDecoration && <ChristmasLights />}
<div class="absolute inset-0 h-full bg-black/40"></div>
...
</div>
The lights use position: fixed to stay at the top of the viewport, with z-index: 100 ensuring they display above the navigation bar. Wire and base colors use hsl(var(--background) / 0.6) to automatically adapt to light/dark themes.
Lights Performance Optimization
The initial version used box-shadow animation for the flickering effect, but it had serious performance issues:
- 50 elements using
will-change: box-shadow, background: Created too many compositing layers, consuming excessive memory box-shadowanimation triggers paint: Requires repainting every frame, high CPU overhead- Animations run continuously: Even when the tab is hidden or Christmas mode is disabled, animations keep consuming resources
Optimization solution:
<script>
function initLightsVisibility() {
// Tab visibility detection - pause animations when not visible
const handleVisibility = () => {
document.documentElement.classList.toggle('lights-paused', document.hidden);
};
handleVisibility();
document.addEventListener('visibilitychange', handleVisibility);
}
if (document.readyState !== 'loading') initLightsVisibility();
document.addEventListener('astro:page-load', initLightsVisibility);
</script>
<style>
.lightrope li {
/* Static box-shadow - does not participate in animation */
box-shadow:
0px 4px 24px 6px rgba(var(--light-red), 1),
0px 2px 8px 2px rgba(var(--light-red), 0.6);
/* Only use opacity + scale animation - GPU compositing, no paint overhead */
animation: flash-bulb 1.2s ease-in-out infinite;
}
/* Sequential lighting effect - groups of 6 bulbs */
.lightrope li:nth-child(6n + 1) { animation-delay: 0s; }
.lightrope li:nth-child(6n + 2) { animation-delay: 0.15s; }
.lightrope li:nth-child(6n + 3) { animation-delay: 0.3s; }
.lightrope li:nth-child(6n + 4) { animation-delay: 0.45s; }
.lightrope li:nth-child(6n + 5) { animation-delay: 0.6s; }
.lightrope li:nth-child(6n) { animation-delay: 0.75s; }
@keyframes flash-bulb {
0%, 100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.4;
transform: scale(0.9);
}
}
/* Pause when tab is not visible */
:global(html.lights-paused) .lightrope li {
animation-play-state: paused;
}
/* Pause when Christmas mode is off */
:global(html:not(.christmas)) .lightrope li {
animation-play-state: paused;
}
</style>
Key optimization points:
| Optimization | Before | After |
|---|---|---|
| Animation properties | box-shadow + background (triggers paint) | opacity + transform: scale() (GPU compositing) |
| Compositing layers | 50+ (will-change) | Auto-managed |
| Tab not visible | Keeps running | Paused |
| Christmas mode off | Keeps running (only hidden) | Paused |
| Visual effect | Random flickering | Sequential lighting + scaling |
Additionally, the color scheme was upgraded to a four-color system (red, green, gold, blue), the glow effect was enhanced with dual-layer box-shadow, and the animation cycle was shortened to 1.2s for a more lively rhythm.
Christmas Hat: SVG Drawing
The Christmas hat on the avatar is drawn with pure SVG, supporting custom size, position, rotation angle, and colors:
---
interface Props {
className?: string;
size?: number | string;
offset?: { x?: number; y?: number };
rotate?: number;
colors?: {
primary?: string;
secondary?: string;
white?: string;
};
}
const {
className,
size = '100%',
offset = { x: -28, y: -50 },
rotate = 10,
colors = {
primary: '#FF4E50',
secondary: '#C00000',
white: '#FFFFFF',
},
} = Astro.props;
// Generate unique ID to avoid gradient conflicts with multiple hats
const uniqueId = Math.random().toString(36).substring(2, 9);
---
<div
class:list={['christmas-hat-wrapper', className]}
style={{
width: typeof size === 'number' ? `${size}px` : size,
height: typeof size === 'number' ? `${size}px` : size,
top: `${offset.y}%`,
left: `${offset.x}%`,
transform: `rotate(${rotate}deg)`,
}}
>
<svg viewBox="0 0 200 200" fill="none">
<defs>
<linearGradient id={`hatGradient_${uniqueId}`} ...>
<stop stop-color={colors.primary}></stop>
<stop offset="1" stop-color={colors.secondary}></stop>
</linearGradient>
</defs>
<!-- Hat body -->
<path d="M179.712 87.522 ..." fill={`url(#hatGradient_${uniqueId})`}></path>
<!-- White trim -->
<path d="M181.512 88.5738 ..." stroke={colors.white} stroke-width="28"></path>
<!-- Pom-pom -->
<circle cx="38.5" cy="144.5" r="19.5" fill={colors.white}></circle>
</svg>
</div>
<style>
.christmas-hat-wrapper {
position: absolute;
pointer-events: none;
z-index: 20;
transition: transform 0.3s ease;
filter: drop-shadow(0px 10px 15px rgba(0, 0, 0, 0.1));
}
</style>
The hat’s position and styling can be flexibly adjusted through props to accommodate avatars of different sizes. A linearGradient creates the hat body gradient effect, and drop-shadow adds depth through shadowing.
Christmas Color Scheme
Theme switching is implemented through CSS custom properties, activated when <html> has the .christmas class:
/* src/styles/christmas/christmas-theme.css */
.christmas {
--christmas-red: 0 70% 45%;
--christmas-green: 150 60% 35%;
--christmas-gold: 43 75% 55%;
/* Override primary color */
--primary: var(--christmas-red);
}
.christmas.dark {
--christmas-red: 0 65% 55%;
--gradient-bg-start: #1a1512;
--gradient-bg-end: #0d1a0d;
}
In the Layout, add the class based on configuration:
<html class:list={[{ christmas: christmasConfig.enabled && christmasConfig.features.christmasColorScheme }]}></html>
Toggle Button
Add a Christmas toggle to the floating button group:
{
christmasConfig.enabled && (
<button id="toggle-christmas" class="rounded-full p-2 shadow-lg backdrop-blur-sm" aria-label="Toggle Christmas effects">
<Icon name="ri:snowy-fill" class="christmas-on h-5 w-5" />
<Icon name="ri:snowy-line" class="christmas-off hidden h-5 w-5" />
</button>
)
}
<script>
import { christmasEnabled, toggleChristmas, initChristmasState } from '@store/christmas';
function init() {
initChristmasState();
const btn = document.getElementById('toggle-christmas');
btn?.addEventListener('click', () => {
toggleChristmas();
});
// Subscribe to state changes, update button icon
christmasEnabled.subscribe((enabled) => {
const onIcon = btn?.querySelector('.christmas-on');
const offIcon = btn?.querySelector('.christmas-off');
onIcon?.classList.toggle('hidden', !enabled);
offIcon?.classList.toggle('hidden', enabled);
});
}
document.addEventListener('astro:page-load', init);
</script>
Christmas Ornament Toggle: A Draggable Decorative Component
Beyond a simple icon button, I also implemented a more interesting interaction: a Christmas ornament hanging from the top-right corner of the page, supporting pull-down drag to trigger the toggle.
Implementation Principle
Uses Motion’s drag API for drag interaction:
useMotionValuetracks drag positionuseTransformderives string heightdragSnapToOriginfor automatic snap-back- Triggers state toggle when drag exceeds threshold
// src/components/christmas/ChristmasOrnamentToggle.tsx
import { cn } from '@lib/utils';
import { useStore } from '@nanostores/react';
import { christmasEnabled, disableChristmas, toggleChristmas } from '@store/christmas';
import { AnimatePresence, motion, useMotionValue, useReducedMotion, useTransform } from 'motion/react';
import { useState } from 'react';
const DRAG_THRESHOLD = 80;
const ORNAMENT_SIZE = 72;
const STRING_HEIGHT = 80;
export function ChristmasOrnamentToggle() {
const isEnabled = useStore(christmasEnabled);
const shouldReduceMotion = useReducedMotion();
const [isPulling, setIsPulling] = useState(false);
const [isHovered, setIsHovered] = useState(false);
const y = useMotionValue(0);
// String height follows the ball's position, ensuring it stays connected
const stringHeight = useTransform(y, (v) => STRING_HEIGHT + Math.max(0, v));
const handleDragEnd = () => {
setIsPulling(false);
const currentY = y.get();
if (currentY > DRAG_THRESHOLD / 2) {
if (isEnabled) {
disableChristmas();
} else {
toggleChristmas();
}
}
};
return (
<div className="fixed top-0 right-20 z-90 flex w-[100px] justify-center">
{/* String - height changes with drag */}
<motion.div
className="pointer-events-none absolute top-0 w-[2px] origin-top bg-linear-to-b from-yellow-700 via-yellow-500 to-yellow-400"
style={{ height: stringHeight }}
/>
{/* Ornament ball - draggable */}
<motion.button
className={cn(
'absolute z-101 cursor-grab touch-none select-none active:cursor-grabbing',
'rounded-full outline-none focus-visible:ring-4 focus-visible:ring-yellow-400/50',
)}
style={{
width: ORNAMENT_SIZE,
height: ORNAMENT_SIZE,
top: STRING_HEIGHT,
y, // Motion drag automatically updates this value
}}
drag={shouldReduceMotion ? false : 'y'}
dragConstraints={{ top: 0, bottom: DRAG_THRESHOLD + 30 }}
dragElastic={0.2}
dragSnapToOrigin // Automatically snaps back on release
onDragStart={() => setIsPulling(true)}
onDragEnd={handleDragEnd}
onClick={() => toggleChristmas()}
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
aria-label={isEnabled ? 'Disable Christmas mode' : 'Enable Christmas mode'}
aria-pressed={isEnabled}
type="button"
>
<OrnamentSvg isEnabled={isEnabled} />
{/* Drag hint */}
<AnimatePresence>
{isEnabled && (isPulling || isHovered) && (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 30 }}
exit={{ opacity: 0 }}
className="pointer-events-none absolute top-1/2 left-1/2 -translate-x-1/2"
>
<div className="rounded-full bg-red-950/90 px-3 py-1 text-[10px] text-white">
Pull to close
</div>
</motion.div>
)}
</AnimatePresence>
</motion.button>
</div>
);
}
Key Technical Points
- String-to-ball synchronization: The
yMotionValue is used for both the ball’s position and the string height calculation, ensuring they remain connected - dragSnapToOrigin: Motion’s built-in snap-back animation that automatically returns to the initial position on release
- useReducedMotion: Respects the user’s animation preferences; when drag is disabled, it falls back to click toggling
- dragElastic: Set to 0.2 for a slight elastic effect when exceeding constraints
Performance Optimization
Mobile Adaptation
Using a custom hook to detect mobile devices and adjust snow density:
const isMobile = useIsMobile();
const shouldReduceMotion = useReducedMotion();
// Use lower density on mobile
const finalIntensity = isMobile ? mobileIntensity : intensity;
// Disable mouse parallax on mobile
const finalParallaxStrength = isMobile ? 0 : parallaxStrength;
// Respect user's animation preference settings
if (shouldReduceMotion || !isChristmasEnabled) {
return null;
}
R3F Canvas Configuration
<Canvas
orthographic
camera={{ zoom: 1, position: [0, 0, 1] }}
gl={{
antialias: false, // Disable anti-aliasing
alpha: true,
powerPreference: 'low-power',
}}
dpr={[1, 1.5]} // Limit pixel ratio
/>
File List
| File | Purpose |
|---|---|
src/constants/site-config.ts | Christmas configuration |
src/store/christmas.ts | State management |
src/components/christmas/SnowfallCanvas.tsx | R3F snow canvas wrapper |
src/components/christmas/SnowParticles.tsx | GLSL shader particle system |
src/components/christmas/ChristmasLights.astro | CSS lights decoration |
src/components/christmas/ChristmasHat.astro | SVG Christmas hat |
src/components/christmas/ChristmasEffects.astro | Dual-layer snow wrapper |
src/components/christmas/ChristmasOrnamentToggle.tsx | Draggable Christmas ornament toggle |
src/styles/christmas/christmas-theme.css | Color scheme |
Summary
The design principles of this Christmas effects module are:
- Pluggable: Controlled via configuration toggle, no impact during non-holiday periods
- Performance-friendly: GLSL shader GPU rendering + CSS animation, with automatic mobile downgrade
- User-controllable: Provides a draggable Christmas ornament toggle, respects
prefers-reduced-motion - Cross-framework: nanostores for Astro/React state synchronization
- Depth perception: Dual-layer rendering + mouse parallax, snowflakes have foreground/background depth
Next year, more holiday effects can be built on this foundation (New Year’s fireworks? Halloween pumpkins?) — the architecture already has room for extension.
Refs
- Shadertoy - Just snow - Snowflake shader reference
- Toby J’s CodePen - Christmas lights reference implementation
This article is being revised as needed. Feel free to leave a comment if you find any errors or omissions.
喜欢的话,留下你的评论吧~