Adding a Pluggable Christmas Effects Module to an Astro Blog

发表于 2025-12-25 01:11 3703 字 19 min read

cos avatar

cos

FE / ACG / 手工 / 深色模式强迫症 / INFP / 兴趣广泛养两只猫的老宅女 / remote

本文实现了一套可插拔的圣诞节日特效模块,通过 React Three Fiber 与 GLSL 着色器实现双层3D雪花飘落、CSS动画彩灯装饰、SVG圣诞帽及可拖拽开关等功能,结合 nanostores 实现 Astro 与 React 间的状态管理,并通过配置开关、性能优化和移动端适配确保用户体验与性能平衡。

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:

Christmas effects showcase 1

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

Christmas effects showcase 2

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.

Christmas effects showcase 3

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 planeGeometry to 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 uLayerStart and uLayerEnd uniforms 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:

  1. Use Motion’s useMotionValue + useSpring to track and smooth mouse position
  2. Pass the smoothed values to the shader’s uMouse uniform
  3. 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:

Layerz-indexShader layers (k)Effect
Background snow1k=0-2Far, small snowflakes, weak parallax
ContentNormal doc flow-Article container
Foreground snow50k=3-5Near, 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 ::before pseudo-elements
  • Wires are simulated using ::after pseudo-elements with border-bottom + border-radius for an arc shape
  • The flickering effect uses @keyframes animation to change background and box-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-shadow animation 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:

OptimizationBeforeAfter
Animation propertiesbox-shadow + background (triggers paint)opacity + transform: scale() (GPU compositing)
Compositing layers50+ (will-change)Auto-managed
Tab not visibleKeeps runningPaused
Christmas mode offKeeps running (only hidden)Paused
Visual effectRandom flickeringSequential 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:

  • useMotionValue tracks drag position
  • useTransform derives string height
  • dragSnapToOrigin for 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

  1. String-to-ball synchronization: The y MotionValue is used for both the ball’s position and the string height calculation, ensuring they remain connected
  2. dragSnapToOrigin: Motion’s built-in snap-back animation that automatically returns to the initial position on release
  3. useReducedMotion: Respects the user’s animation preferences; when drag is disabled, it falls back to click toggling
  4. 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

FilePurpose
src/constants/site-config.tsChristmas configuration
src/store/christmas.tsState management
src/components/christmas/SnowfallCanvas.tsxR3F snow canvas wrapper
src/components/christmas/SnowParticles.tsxGLSL shader particle system
src/components/christmas/ChristmasLights.astroCSS lights decoration
src/components/christmas/ChristmasHat.astroSVG Christmas hat
src/components/christmas/ChristmasEffects.astroDual-layer snow wrapper
src/components/christmas/ChristmasOrnamentToggle.tsxDraggable Christmas ornament toggle
src/styles/christmas/christmas-theme.cssColor scheme

Summary

The design principles of this Christmas effects module are:

  1. Pluggable: Controlled via configuration toggle, no impact during non-holiday periods
  2. Performance-friendly: GLSL shader GPU rendering + CSS animation, with automatic mobile downgrade
  3. User-controllable: Provides a draggable Christmas ornament toggle, respects prefers-reduced-motion
  4. Cross-framework: nanostores for Astro/React state synchronization
  5. 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

This article is being revised as needed. Feel free to leave a comment if you find any errors or omissions.

喜欢的话,留下你的评论吧~

© 2020 - 2026 cos @cosine
Powered by theme astro-koharu · Inspired by Shoka